summaryrefslogtreecommitdiffstats
path: root/comm/mail/components/im
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mail/components/im
parentInitial commit. (diff)
downloadthunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz
thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/mail/components/im')
-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
222 files changed, 16628 insertions, 0 deletions
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',
+ },
+]