summaryrefslogtreecommitdiffstats
path: root/comm/mail/components/im/content/chat-messenger.js
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/components/im/content/chat-messenger.js')
-rw-r--r--comm/mail/components/im/content/chat-messenger.js2162
1 files changed, 2162 insertions, 0 deletions
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());