summaryrefslogtreecommitdiffstats
path: root/comm/mail/base/content/widgets/glodaFacet.js
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/base/content/widgets/glodaFacet.js')
-rw-r--r--comm/mail/base/content/widgets/glodaFacet.js1823
1 files changed, 1823 insertions, 0 deletions
diff --git a/comm/mail/base/content/widgets/glodaFacet.js b/comm/mail/base/content/widgets/glodaFacet.js
new file mode 100644
index 0000000000..c8d1e78dd8
--- /dev/null
+++ b/comm/mail/base/content/widgets/glodaFacet.js
@@ -0,0 +1,1823 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 DateFacetVis, FacetContext */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+ );
+ const { TagUtils } = ChromeUtils.import("resource:///modules/TagUtils.jsm");
+ const { FacetUtils } = ChromeUtils.import(
+ "resource:///modules/gloda/Facet.jsm"
+ );
+ const { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+ );
+ const { Gloda } = ChromeUtils.import("resource:///modules/gloda/Gloda.jsm");
+
+ var glodaFacetStrings = Services.strings.createBundle(
+ "chrome://messenger/locale/glodaFacetView.properties"
+ );
+
+ class MozFacetDate extends HTMLElement {
+ get build() {
+ return this.buildFunc;
+ }
+
+ get brushItems() {
+ return items => this.vis.hoverItems(items);
+ }
+
+ get clearBrushedItems() {
+ return () => this.vis.clearHover();
+ }
+
+ connectedCallback() {
+ const wrapper = document.createElement("div");
+ wrapper.classList.add("facet", "date-wrapper");
+
+ const h2 = document.createElement("h2");
+
+ const canvas = document.createElement("div");
+ canvas.classList.add("date-vis-frame");
+
+ const zoomOut = document.createElement("div");
+ zoomOut.classList.add("facet-date-zoom-out");
+ zoomOut.setAttribute("role", "image");
+ zoomOut.addEventListener("click", () => FacetContext.zoomOut());
+
+ wrapper.appendChild(h2);
+ wrapper.appendChild(canvas);
+ wrapper.appendChild(zoomOut);
+ this.appendChild(wrapper);
+
+ this.canUpdate = true;
+ this.canvasNode = canvas;
+ this.vis = null;
+ if ("faceter" in this) {
+ this.buildFunc(true);
+ }
+ }
+
+ buildFunc(aDoSize) {
+ if (!this.vis) {
+ this.vis = new DateFacetVis(this, this.canvasNode);
+ this.vis.build();
+ } else {
+ while (this.canvasNode.hasChildNodes()) {
+ this.canvasNode.lastChild.remove();
+ }
+ if (aDoSize) {
+ this.vis.build();
+ } else {
+ this.vis.rebuild();
+ }
+ }
+ }
+ }
+
+ customElements.define("facet-date", MozFacetDate);
+
+ /**
+ * MozFacetResultsMessage shows the search results for the string entered in gloda-searchbox.
+ *
+ * @augments {HTMLElement}
+ */
+ class MozFacetResultsMessage extends HTMLElement {
+ connectedCallback() {
+ const header = document.createElement("div");
+ header.classList.add("results-message-header");
+
+ this.countNode = document.createElement("h2");
+ this.countNode.classList.add("results-message-count");
+
+ this.toggleTimeline = document.createElement("button");
+ this.toggleTimeline.setAttribute("id", "date-toggle");
+ this.toggleTimeline.setAttribute("tabindex", 0);
+ this.toggleTimeline.classList.add("gloda-timeline-button");
+ this.toggleTimeline.addEventListener("click", () => {
+ FacetContext.toggleTimeline();
+ });
+
+ const timelineImage = document.createElement("img");
+ timelineImage.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/popular.svg"
+ );
+ timelineImage.setAttribute("alt", "");
+ this.toggleTimeline.appendChild(timelineImage);
+
+ this.toggleText = document.createElement("span");
+ this.toggleTimeline.appendChild(this.toggleText);
+
+ const sortDiv = document.createElement("div");
+ sortDiv.classList.add("results-message-sort-bar");
+
+ this.sortSelect = document.createElement("select");
+ this.sortSelect.setAttribute("id", "sortby");
+ let sortByPref = Services.prefs.getIntPref("gloda.facetview.sortby");
+
+ let relevanceItem = document.createElement("option");
+ relevanceItem.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.message.sort.relevance2"
+ );
+ relevanceItem.setAttribute("value", "-dascore");
+ relevanceItem.toggleAttribute(
+ "selected",
+ sortByPref <= 0 || sortByPref == 2 || sortByPref > 3
+ );
+ this.sortSelect.appendChild(relevanceItem);
+
+ let dateItem = document.createElement("option");
+ dateItem.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.message.sort.date2"
+ );
+ dateItem.setAttribute("value", "-date");
+ dateItem.toggleAttribute("selected", sortByPref == 1 || sortByPref == 3);
+ this.sortSelect.appendChild(dateItem);
+
+ this.messagesNode = document.createElement("div");
+ this.messagesNode.classList.add("messages");
+
+ header.appendChild(this.countNode);
+ header.appendChild(this.toggleTimeline);
+ header.appendChild(sortDiv);
+
+ sortDiv.appendChild(this.sortSelect);
+
+ this.appendChild(header);
+ this.appendChild(this.messagesNode);
+ }
+
+ setMessages(messages) {
+ let topMessagesPluralFormat = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.header.countLabel.NMessages"
+ );
+ let outOfPluralFormat = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.header.countLabel.ofN"
+ );
+ let groupingFormat = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.header.countLabel.grouping"
+ );
+
+ let displayCount = messages.length;
+ let totalCount = FacetContext.activeSet.length;
+
+ // set the count so CSS selectors can know what the results look like
+ this.setAttribute("state", totalCount <= 0 ? "empty" : "some");
+
+ let topMessagesStr = PluralForm.get(
+ displayCount,
+ topMessagesPluralFormat
+ ).replace("#1", displayCount.toLocaleString());
+ let outOfStr = PluralForm.get(totalCount, outOfPluralFormat).replace(
+ "#1",
+ totalCount.toLocaleString()
+ );
+
+ this.countNode.textContent = groupingFormat
+ .replace("#1", topMessagesStr)
+ .replace("#2", outOfStr);
+
+ this.toggleText.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.message.timeline.label"
+ );
+
+ let sortByPref = Services.prefs.getIntPref("gloda.facetview.sortby");
+ this.sortSelect.addEventListener("change", () => {
+ if (sortByPref >= 2) {
+ Services.prefs.setIntPref(
+ "gloda.facetview.sortby",
+ this.sortSelect.value == "-dascore" ? 2 : 3
+ );
+ }
+
+ FacetContext.sortBy = this.sortSelect.value;
+ });
+
+ while (this.messagesNode.hasChildNodes()) {
+ this.messagesNode.lastChild.remove();
+ }
+ try {
+ // -- Messages
+ for (let message of messages) {
+ let msgNode = document.createElement("facet-result-message");
+ msgNode.message = message;
+ msgNode.setAttribute("class", "message");
+ this.messagesNode.appendChild(msgNode);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+
+ customElements.define("facet-results-message", MozFacetResultsMessage);
+
+ class MozFacetBoolean extends HTMLElement {
+ constructor() {
+ super();
+
+ this.addEventListener("mouseover", event => {
+ FacetContext.hoverFacet(
+ this.faceter,
+ this.faceter.attrDef,
+ true,
+ this.trueValues
+ );
+ });
+
+ this.addEventListener("mouseout", event => {
+ FacetContext.unhoverFacet(
+ this.faceter,
+ this.faceter.attrDef,
+ true,
+ this.trueValues
+ );
+ });
+ }
+
+ connectedCallback() {
+ this.addChildren();
+
+ this.canUpdate = true;
+ this.bubble.addEventListener("click", event => {
+ return this.bubbleClicked(event);
+ });
+
+ if ("faceter" in this) {
+ this.build(true);
+ }
+ }
+
+ addChildren() {
+ this.bubble = document.createElement("span");
+ this.bubble.classList.add("facet-checkbox-bubble");
+
+ this.checkbox = document.createElement("input");
+ this.checkbox.setAttribute("type", "checkbox");
+
+ this.labelNode = document.createElement("span");
+ this.labelNode.classList.add("facet-checkbox-label");
+
+ this.countNode = document.createElement("span");
+ this.countNode.classList.add("facet-checkbox-count");
+
+ this.bubble.appendChild(this.checkbox);
+ this.bubble.appendChild(this.labelNode);
+ this.bubble.appendChild(this.countNode);
+
+ this.appendChild(this.bubble);
+ }
+
+ set disabled(val) {
+ if (val) {
+ this.setAttribute("disabled", "true");
+ this.checkbox.setAttribute("disabled", "true");
+ } else {
+ this.removeAttribute("disabled");
+ this.checkbox.removeAttribute("disabled");
+ }
+ }
+
+ get disabled() {
+ return this.getAttribute("disabled") == "true";
+ }
+
+ set checked(val) {
+ if (this.checked == val) {
+ return;
+ }
+ this.checkbox.checked = val;
+ if (val) {
+ this.setAttribute("checked", "true");
+ if (!this.disabled) {
+ FacetContext.addFacetConstraint(this.faceter, true, this.trueGroups);
+ }
+ } else {
+ this.removeAttribute("checked");
+ this.checkbox.removeAttribute("checked");
+ if (!this.disabled) {
+ FacetContext.removeFacetConstraint(
+ this.faceter,
+ true,
+ this.trueGroups
+ );
+ }
+ }
+ this.checkStateChanged();
+ }
+
+ get checked() {
+ return this.getAttribute("checked") == "true";
+ }
+
+ extraSetup() {}
+
+ checkStateChanged() {}
+
+ brushItems() {}
+
+ clearBrushedItems() {}
+
+ build(firstTime) {
+ if (firstTime) {
+ this.labelNode.textContent = this.facetDef.strings.facetNameLabel;
+ this.checkbox.setAttribute(
+ "aria-label",
+ this.facetDef.strings.facetNameLabel
+ );
+ this.trueValues = [];
+ }
+
+ // If we do not currently have a constraint applied and there is only
+ // one (or no) group, then: disable us, but reflect the underlying
+ // state of the data (checked or non-checked)
+ if (!this.faceter.constraint && this.orderedGroups.length <= 1) {
+ this.disabled = true;
+ let count = 0;
+ if (this.orderedGroups.length) {
+ // true case?
+ if (this.orderedGroups[0][0]) {
+ count = this.orderedGroups[0][1].length;
+ this.checked = true;
+ } else {
+ this.checked = false;
+ }
+ }
+ this.countNode.textContent = count.toLocaleString();
+ return;
+ }
+ // if we were disabled checked before, clear ourselves out
+ if (this.disabled && this.checked) {
+ this.checked = false;
+ }
+ this.disabled = false;
+
+ // if we are here, we have our 2 groups, find true...
+ // (note: it is possible to get jerked around by null values
+ // currently, so leave a reasonable failure case)
+ this.trueValues = [];
+ this.trueGroups = [true];
+ for (let groupPair of this.orderedGroups) {
+ if (groupPair[0]) {
+ this.trueValues = groupPair[1];
+ }
+ }
+
+ this.countNode.textContent = this.trueValues.length.toLocaleString();
+ }
+
+ bubbleClicked(event) {
+ if (!this.disabled) {
+ this.checked = !this.checked;
+ }
+ event.stopPropagation();
+ }
+ }
+
+ customElements.define("facet-boolean", MozFacetBoolean);
+
+ class MozFacetBooleanFiltered extends MozFacetBoolean {
+ static get observedAttributes() {
+ return ["checked", "disabled"];
+ }
+
+ connectedCallback() {
+ super.addChildren();
+
+ this.filterNode = document.createElement("select");
+ this.filterNode.classList.add("facet-filter-list");
+ this.appendChild(this.filterNode);
+
+ this.canUpdate = true;
+ this.bubble.addEventListener("click", event => {
+ return super.bubbleClicked(event);
+ });
+
+ this.extraSetup();
+
+ if ("faceter" in this) {
+ this.build(true);
+ }
+
+ this._updateAttributes();
+ }
+
+ attributeChangedCallback() {
+ this._updateAttributes();
+ }
+
+ _updateAttributes() {
+ if (!this.checkbox) {
+ return;
+ }
+
+ if (this.hasAttribute("checked")) {
+ this.checkbox.setAttribute("checked", this.getAttribute("checked"));
+ } else {
+ this.checkbox.removeAttribute("checked");
+ }
+
+ if (this.hasAttribute("disabled")) {
+ this.checkbox.setAttribute("disabled", this.getAttribute("disabled"));
+ } else {
+ this.checkbox.removeAttribute("disabled");
+ }
+ }
+
+ extraSetup() {
+ this.groupDisplayProperty = this.getAttribute("groupDisplayProperty");
+
+ this.filterNode.addEventListener("change", event =>
+ this.filterChanged(event)
+ );
+
+ this.selectedValue = "all";
+ }
+
+ build(firstTime) {
+ if (firstTime) {
+ this.labelNode.textContent = this.facetDef.strings.facetNameLabel;
+ this.checkbox.setAttribute(
+ "aria-label",
+ this.facetDef.strings.facetNameLabel
+ );
+ this.trueValues = [];
+ }
+
+ // Only update count if anything other than "all" is selected.
+ // Otherwise we lose the set of attachment types in our select box,
+ // and that makes us sad. We do want to update on "all" though
+ // because other facets may further reduce the number of attachments
+ // we see. (Or if this is not just being used for attachments, it
+ // still holds.)
+ if (this.selectedValue != "all") {
+ let count = 0;
+ for (let groupPair of this.orderedGroups) {
+ if (groupPair[0] != null) {
+ count += groupPair[1].length;
+ }
+ }
+ this.countNode.textContent = count.toLocaleString();
+ return;
+ }
+
+ while (this.filterNode.hasChildNodes()) {
+ this.filterNode.lastChild.remove();
+ }
+
+ let allNode = document.createElement("option");
+ allNode.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.filter." +
+ this.attrDef.attributeName +
+ ".allLabel"
+ );
+ allNode.setAttribute("value", "all");
+ if (this.selectedValue == "all") {
+ allNode.setAttribute("selected", "selected");
+ }
+ this.filterNode.appendChild(allNode);
+
+ // if we are here, we have our 2 groups, find true...
+ // (note: it is possible to get jerked around by null values
+ // currently, so leave a reasonable failure case)
+ // empty true groups is for the checkbox
+ this.trueGroups = [];
+ // the real true groups is the actual true values for our explicit
+ // filtering
+ this.realTrueGroups = [];
+ this.trueValues = [];
+ this.falseValues = [];
+ let selectNodes = [];
+ for (let groupPair of this.orderedGroups) {
+ if (groupPair[0] === null) {
+ this.falseValues.push.apply(this.falseValues, groupPair[1]);
+ } else {
+ this.trueValues.push.apply(this.trueValues, groupPair[1]);
+
+ let groupValue = groupPair[0];
+ let selNode = document.createElement("option");
+ selNode.textContent = groupValue[this.groupDisplayProperty];
+ selNode.setAttribute("value", this.realTrueGroups.length);
+ if (this.selectedValue == groupValue.category) {
+ selNode.setAttribute("selected", "selected");
+ }
+ selectNodes.push(selNode);
+
+ this.realTrueGroups.push(groupValue);
+ }
+ }
+ selectNodes.sort((a, b) => {
+ return a.textContent.localeCompare(b.textContent);
+ });
+ selectNodes.forEach(selNode => {
+ this.filterNode.appendChild(selNode);
+ });
+
+ this.disabled = !this.trueValues.length;
+
+ this.countNode.textContent = this.trueValues.length.toLocaleString();
+ }
+
+ checkStateChanged() {
+ // if they un-check us, revert our value to all.
+ if (!this.checked) {
+ this.selectedValue = "all";
+ }
+ }
+
+ filterChanged(event) {
+ if (!this.checked) {
+ return;
+ }
+ if (this.filterNode.value == "all") {
+ this.selectedValue = "all";
+ FacetContext.addFacetConstraint(
+ this.faceter,
+ true,
+ this.trueGroups,
+ false,
+ true
+ );
+ } else {
+ let groupValue = this.realTrueGroups[parseInt(this.filterNode.value)];
+ this.selectedValue = groupValue.category;
+ FacetContext.addFacetConstraint(
+ this.faceter,
+ true,
+ [groupValue],
+ false,
+ true
+ );
+ }
+ }
+ }
+
+ customElements.define("facet-boolean-filtered", MozFacetBooleanFiltered);
+
+ class MozFacetDiscrete extends HTMLElement {
+ constructor() {
+ super();
+
+ this.addEventListener("click", event => {
+ this.showPopup(event);
+ });
+
+ this.addEventListener("keypress", event => {
+ if (event.keyCode != KeyEvent.DOM_VK_RETURN) {
+ return;
+ }
+ this.showPopup(event);
+ });
+
+ this.addEventListener("keypress", event => {
+ this.activateLink(event);
+ });
+
+ this.addEventListener("mouseover", event => {
+ // we dispatch based on the class of the thing we clicked on.
+ // there are other ways we could accomplish this, but they all sorta suck.
+ if (
+ event.target.hasAttribute("class") &&
+ event.target.classList.contains("bar-link")
+ ) {
+ this.barHovered(event.target.parentNode, true);
+ }
+ });
+
+ this.addEventListener("mouseout", event => {
+ // we dispatch based on the class of the thing we clicked on.
+ // there are other ways we could accomplish this, but they all sorta suck.
+ if (
+ event.target.hasAttribute("class") &&
+ event.target.classList.contains("bar-link")
+ ) {
+ this.barHoverGone(event.target.parentNode, true);
+ }
+ });
+ }
+
+ connectedCallback() {
+ const facet = document.createElement("div");
+ facet.classList.add("facet");
+
+ this.nameNode = document.createElement("h2");
+
+ this.contentBox = document.createElement("div");
+ this.contentBox.classList.add("facet-content");
+
+ this.includeLabel = document.createElement("h3");
+ this.includeLabel.classList.add("facet-included-header");
+
+ this.includeList = document.createElement("ul");
+ this.includeList.classList.add("facet-included", "barry");
+
+ this.remainderLabel = document.createElement("h3");
+ this.remainderLabel.classList.add("facet-remaindered-header");
+
+ this.remainderList = document.createElement("ul");
+ this.remainderList.classList.add("facet-remaindered", "barry");
+
+ this.excludeLabel = document.createElement("h3");
+ this.excludeLabel.classList.add("facet-excluded-header");
+
+ this.excludeList = document.createElement("ul");
+ this.excludeList.classList.add("facet-excluded", "barry");
+
+ this.moreButton = document.createElement("button");
+ this.moreButton.classList.add("facet-more");
+ this.moreButton.setAttribute("needed", "false");
+ this.moreButton.setAttribute("tabindex", "0");
+
+ this.contentBox.appendChild(this.includeLabel);
+ this.contentBox.appendChild(this.includeList);
+ this.contentBox.appendChild(this.remainderLabel);
+ this.contentBox.appendChild(this.remainderList);
+ this.contentBox.appendChild(this.excludeLabel);
+ this.contentBox.appendChild(this.excludeList);
+ this.contentBox.appendChild(this.moreButton);
+
+ facet.appendChild(this.nameNode);
+ facet.appendChild(this.contentBox);
+
+ this.appendChild(facet);
+
+ this.canUpdate = false;
+
+ if ("faceter" in this) {
+ this.build(true);
+ }
+ }
+
+ build(firstTime) {
+ // -- Header Building
+ this.nameNode.textContent = this.facetDef.strings.facetNameLabel;
+
+ // - include
+ // setup the include label
+ if ("includeLabel" in this.facetDef.strings) {
+ this.includeLabel.textContent = this.facetDef.strings.includeLabel;
+ } else {
+ this.includeLabel.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.included.fallbackLabel"
+ );
+ }
+ this.includeLabel.setAttribute("state", "empty");
+
+ // - exclude
+ // setup the exclude label
+ if ("excludeLabel" in this.facetDef.strings) {
+ this.excludeLabel.textContent = this.facetDef.strings.excludeLabel;
+ } else {
+ this.excludeLabel.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.excluded.fallbackLabel"
+ );
+ }
+ this.excludeLabel.setAttribute("state", "empty");
+
+ // - remainder
+ // setup the remainder label
+ if ("remainderLabel" in this.facetDef.strings) {
+ this.remainderLabel.textContent = this.facetDef.strings.remainderLabel;
+ } else {
+ this.remainderLabel.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.remainder.fallbackLabel"
+ );
+ }
+
+ // -- House-cleaning
+ // -- All/Top mode decision
+ this.modes = ["all"];
+ if (this.maxDisplayRows >= this.orderedGroups.length) {
+ this.mode = "all";
+ } else {
+ // top mode must be used
+ this.modes.push("top");
+ this.mode = "top";
+ this.topGroups = FacetUtils.makeTopGroups(
+ this.attrDef,
+ this.orderedGroups,
+ this.maxDisplayRows
+ );
+ // setup the more button string
+ let groupCount = this.orderedGroups.length;
+ this.moreButton.textContent = PluralForm.get(
+ groupCount,
+ glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.mode.top.listAllLabel"
+ )
+ ).replace("#1", groupCount);
+ }
+
+ // -- Row Building
+ this.buildRows();
+ }
+
+ changeMode(newMode) {
+ this.mode = newMode;
+ this.setAttribute("mode", newMode);
+ this.buildRows();
+ }
+
+ buildRows() {
+ let nounDef = this.nounDef;
+ let useGroups = this.mode == "all" ? this.orderedGroups : this.topGroups;
+
+ // should we just rely on automatic string coercion?
+ this.moreButton.setAttribute(
+ "needed",
+ this.mode == "top" ? "true" : "false"
+ );
+
+ let constraint = this.faceter.constraint;
+
+ // -- empty all of our display buckets...
+ let remainderList = this.remainderList;
+ while (remainderList.hasChildNodes()) {
+ remainderList.lastChild.remove();
+ }
+ let includeList = this.includeList;
+ let excludeList = this.excludeList;
+ while (includeList.hasChildNodes()) {
+ includeList.lastChild.remove();
+ }
+ while (excludeList.hasChildNodes()) {
+ excludeList.lastChild.remove();
+ }
+
+ // -- first pass, check for ambiguous labels
+ // It's possible that multiple groups are identified by the same short
+ // string, in which case we want to use the longer string to
+ // disambiguate. For example, un-merged contacts can result in
+ // multiple identities having contacts with the same name. In that
+ // case we want to display both the contact name and the identity
+ // name.
+ // This is generically addressed by using the userVisibleString function
+ // defined on the noun type if it is defined. It takes an argument
+ // indicating whether it should be a short string or a long string.
+ // Our algorithm is somewhat dumb. We get the short strings, put them
+ // in a dictionary that maps to whether they are ambiguous or not. We
+ // do not attempt to map based on their id, so then when it comes time
+ // to actually build the labels, we must build the short string and
+ // then re-call for the long name. We could be smarter by building
+ // a list of the input values that resulted in the output string and
+ // then using that to back-update the id map, but it's more compelx and
+ // the performance difference is unlikely to be meaningful.
+ let ambiguousKeyValues;
+ if ("userVisibleString" in nounDef) {
+ ambiguousKeyValues = {};
+ for (let groupPair of useGroups) {
+ let [groupValue] = groupPair;
+
+ // skip null values, they are handled by the none special-case
+ if (groupValue == null) {
+ continue;
+ }
+
+ let groupStr = nounDef.userVisibleString(groupValue, false);
+ // We use hasOwnProperty because it is possible that groupStr could
+ // be the same as the name of one of the attributes on
+ // Object.prototype.
+ if (ambiguousKeyValues.hasOwnProperty(groupStr)) {
+ ambiguousKeyValues[groupStr] = true;
+ } else {
+ ambiguousKeyValues[groupStr] = false;
+ }
+ }
+ }
+
+ // -- create the items, assigning them to the right list based on
+ // existing constraint values
+ for (let groupPair of useGroups) {
+ let [groupValue, groupItems] = groupPair;
+ let li = document.createElement("li");
+ li.setAttribute("class", "bar");
+ li.setAttribute("tabindex", "0");
+ li.setAttribute("role", "link");
+ li.setAttribute("aria-haspopup", "true");
+ li.groupValue = groupValue;
+ li.setAttribute("groupValue", groupValue);
+ li.groupItems = groupItems;
+
+ let countSpan = document.createElement("span");
+ countSpan.setAttribute("class", "bar-count");
+ countSpan.textContent = groupItems.length.toLocaleString();
+ li.appendChild(countSpan);
+
+ let label = document.createElement("span");
+ label.setAttribute("class", "bar-link");
+
+ // The null value is a special indicator for 'none'
+ if (groupValue == null) {
+ if ("noneLabel" in this.facetDef.strings) {
+ label.textContent = this.facetDef.strings.noneLabel;
+ } else {
+ label.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.facets.noneLabel"
+ );
+ }
+ } else {
+ // Otherwise stringify the group object
+ let labelStr;
+ if (ambiguousKeyValues) {
+ labelStr = nounDef.userVisibleString(groupValue, false);
+ if (ambiguousKeyValues[labelStr]) {
+ labelStr = nounDef.userVisibleString(groupValue, true);
+ }
+ } else if ("labelFunc" in this.facetDef) {
+ labelStr = this.facetDef.labelFunc(groupValue);
+ } else {
+ labelStr = groupValue.toLocaleString().substring(0, 80);
+ }
+ label.textContent = labelStr;
+ label.setAttribute("title", labelStr);
+ }
+ li.appendChild(label);
+
+ // root it under the appropriate list
+ if (constraint) {
+ if (constraint.isIncludedGroup(groupValue)) {
+ li.setAttribute("variety", "include");
+ includeList.appendChild(li);
+ } else if (constraint.isExcludedGroup(groupValue)) {
+ li.setAttribute("variety", "exclude");
+ excludeList.appendChild(li);
+ } else {
+ li.setAttribute("variety", "remainder");
+ remainderList.appendChild(li);
+ }
+ } else {
+ li.setAttribute("variety", "remainder");
+ remainderList.appendChild(li);
+ }
+ }
+
+ this.updateHeaderStates();
+ }
+
+ /**
+ * - Mark the include/exclude headers as "some" if there is anything in their
+ * - lists, mark the remainder header as "needed" if either of include /
+ * - exclude exist so we need that label.
+ */
+ updateHeaderStates(items) {
+ this.includeLabel.setAttribute(
+ "state",
+ this.includeList.childElementCount ? "some" : "empty"
+ );
+ this.excludeLabel.setAttribute(
+ "state",
+ this.excludeList.childElementCount ? "some" : "empty"
+ );
+ this.remainderLabel.setAttribute(
+ "needed",
+ (this.includeList.childElementCount ||
+ this.excludeList.childElementCount) &&
+ this.remainderList.childElementCount
+ ? "true"
+ : "false"
+ );
+
+ // nuke the style attributes.
+ this.includeLabel.removeAttribute("style");
+ this.excludeLabel.removeAttribute("style");
+ this.remainderLabel.removeAttribute("style");
+ }
+
+ brushItems(items) {}
+
+ clearBrushedItems() {}
+
+ afterListVisible(variety, callback) {
+ let labelNode = this[variety + "Label"];
+ let listNode = this[variety + "List"];
+
+ // if there are already things displayed, no need
+ if (listNode.childElementCount) {
+ callback();
+ return;
+ }
+
+ let remListVisible = this.remainderLabel.getAttribute("needed") == "true";
+ let remListShouldBeVisible = this.remainderList.childElementCount > 1;
+
+ labelNode.setAttribute("state", "some");
+
+ let showNodes = [labelNode];
+ if (remListVisible != remListShouldBeVisible) {
+ showNodes = [labelNode, this.remainderLabel];
+ }
+
+ showNodes.forEach(node => (node.style.display = "block"));
+
+ callback();
+ }
+
+ _flyBarAway(barNode, variety, callback) {
+ function getRect(aElement) {
+ let box = aElement.getBoundingClientRect();
+ let documentElement = aElement.ownerDocument.documentElement;
+ return {
+ top: box.top + window.pageYOffset - documentElement.clientTop,
+ left: box.left + window.pageXOffset - documentElement.clientLeft,
+ width: box.width,
+ height: box.height,
+ };
+ }
+ // figure out our origin location prior to adding the target or it
+ // will shift us down.
+ let origin = getRect(barNode);
+
+ // clone the node into its target location
+ let targetNode = barNode.cloneNode(true);
+ targetNode.groupValue = barNode.groupValue;
+ targetNode.groupItems = barNode.groupItems;
+ targetNode.setAttribute("variety", variety);
+
+ let targetParent = this[variety + "List"];
+ targetParent.appendChild(targetNode);
+
+ // create a flying clone
+ let flyingNode = barNode.cloneNode(true);
+
+ let dest = getRect(targetNode);
+
+ // if the flying box wants to go higher than the content box goes, just
+ // send it to the top of the content box instead.
+ let contentRect = getRect(this.contentBox);
+ if (dest.top < contentRect.top) {
+ dest.top = contentRect.top;
+ }
+
+ // likewise if it wants to go further south than the content box, stop
+ // that
+ if (dest.top > contentRect.top + contentRect.height) {
+ dest.top = contentRect.top + contentRect.height - dest.height;
+ }
+
+ flyingNode.style.position = "absolute";
+ flyingNode.style.width = origin.width + "px";
+ flyingNode.style.height = origin.height + "px";
+ flyingNode.style.top = origin.top + "px";
+ flyingNode.style.left = origin.left + "px";
+ flyingNode.style.zIndex = 1000;
+
+ flyingNode.style.transitionDuration =
+ Math.abs(dest.top - origin.top) * 2 + "ms";
+ flyingNode.style.transitionProperty = "top, left";
+
+ flyingNode.addEventListener("transitionend", () => {
+ barNode.remove();
+ targetNode.style.display = "block";
+ flyingNode.remove();
+
+ if (callback) {
+ setTimeout(callback, 50);
+ }
+ });
+
+ document.body.appendChild(flyingNode);
+
+ // Adding setTimeout to improve the facet-discrete animation.
+ // See Bug 1439323 for more detail.
+ setTimeout(() => {
+ // animate the flying clone... flying!
+ window.requestAnimationFrame(() => {
+ flyingNode.style.top = dest.top + "px";
+ flyingNode.style.left = dest.left + "px";
+ });
+
+ // hide the target (cloned) node
+ targetNode.style.display = "none";
+
+ // hide the original node and remove its JS properties
+ barNode.style.visibility = "hidden";
+ delete barNode.groupValue;
+ delete barNode.groupItems;
+ }, 100);
+ }
+
+ barClicked(barNode, variety) {
+ let groupValue = barNode.groupValue;
+ // These determine what goAnimate actually does.
+ // flyAway allows us to cancel flying in the case the constraint is
+ // being fully dropped and so the facet is just going to get rebuilt
+ let flyAway = true;
+
+ const goAnimate = () => {
+ setTimeout(() => {
+ if (flyAway) {
+ this.afterListVisible(variety, () => {
+ this._flyBarAway(barNode, variety, () => {
+ this.updateHeaderStates();
+ });
+ });
+ }
+ }, 0);
+ };
+
+ // Immediately apply the facet change, triggering the animation after
+ // the faceting completes.
+ if (variety == "remainder") {
+ let currentVariety = barNode.getAttribute("variety");
+ let constraintGone = FacetContext.removeFacetConstraint(
+ this.faceter,
+ currentVariety == "include",
+ [groupValue],
+ goAnimate
+ );
+
+ // we will automatically rebuild if the constraint is gone, so
+ // just make the animation a no-op.
+ if (constraintGone) {
+ flyAway = false;
+ }
+ } else {
+ // include/exclude
+ let revalidate = FacetContext.addFacetConstraint(
+ this.faceter,
+ variety == "include",
+ [groupValue],
+ false,
+ false,
+ goAnimate
+ );
+
+ // revalidate means we need to blow away the other dudes, in which
+ // case it makes the most sense to just trigger a rebuild of ourself
+ if (revalidate) {
+ flyAway = false;
+ this.build(false);
+ }
+ }
+ }
+
+ barHovered(barNode, aInclude) {
+ let groupValue = barNode.groupValue;
+ let groupItems = barNode.groupItems;
+
+ FacetContext.hoverFacet(
+ this.faceter,
+ this.attrDef,
+ groupValue,
+ groupItems
+ );
+ }
+
+ /**
+ * HoverGone! HoverGone!
+ * We know it's gone, but where has it gone?
+ */
+ barHoverGone(barNode, include) {
+ let groupValue = barNode.groupValue;
+ let groupItems = barNode.groupItems;
+
+ FacetContext.unhoverFacet(
+ this.faceter,
+ this.attrDef,
+ groupValue,
+ groupItems
+ );
+ }
+
+ includeFacet(node) {
+ this.barClicked(
+ node,
+ node.getAttribute("variety") == "remainder" ? "include" : "remainder"
+ );
+ }
+
+ undoFacet(node) {
+ this.barClicked(
+ node,
+ node.getAttribute("variety") == "remainder" ? "include" : "remainder"
+ );
+ }
+
+ excludeFacet(node) {
+ this.barClicked(node, "exclude");
+ }
+
+ showPopup(event) {
+ try {
+ // event.target could be the <li> node, or a span inside
+ // of it, or perhaps the facet-more button, or maybe something
+ // else that we'll handle in the next version. We walk up its
+ // parent chain until we get to the right level of the DOM
+ // hierarchy, or the facet-content which seems to be the root.
+ if (this.currentNode) {
+ this.currentNode.removeAttribute("selected");
+ }
+
+ let node = event.target;
+
+ while (
+ !(node && node.hasAttribute && node.hasAttribute("class")) ||
+ (!node.classList.contains("bar") &&
+ !node.classList.contains("facet-more") &&
+ !node.classList.contains("facet-content"))
+ ) {
+ node = node.parentNode;
+ }
+
+ if (!(node && node.hasAttribute && node.hasAttribute("class"))) {
+ return false;
+ }
+
+ this.currentNode = node;
+ node.setAttribute("selected", "true");
+
+ if (node.classList.contains("bar")) {
+ document.querySelector("facet-popup-menu").show(event, this, node);
+ } else if (node.classList.contains("facet-more")) {
+ this.changeMode("all");
+ }
+
+ return false;
+ } catch (e) {
+ return console.error(e);
+ }
+ }
+
+ activateLink(event) {
+ try {
+ let node = event.target;
+
+ while (
+ !node.hasAttribute("class") ||
+ (!node.classList.contains("facet-more") &&
+ !node.classList.contains("facet-content"))
+ ) {
+ node = node.parentNode;
+ }
+
+ if (node.classList.contains("facet-more")) {
+ this.changeMode("all");
+ }
+
+ return false;
+ } catch (e) {
+ return console.error(e);
+ }
+ }
+ }
+
+ customElements.define("facet-discrete", MozFacetDiscrete);
+
+ class MozFacetPopupMenu extends HTMLElement {
+ constructor() {
+ super();
+
+ this.addEventListener("keypress", event => {
+ switch (event.keyCode) {
+ case KeyEvent.DOM_VK_ESCAPE:
+ this.hide();
+ break;
+
+ case KeyEvent.DOM_VK_DOWN:
+ this.moveFocus(event, 1);
+ break;
+
+ case KeyEvent.DOM_VK_TAB:
+ if (event.shiftKey) {
+ this.moveFocus(event, -1);
+ break;
+ }
+
+ this.moveFocus(event, 1);
+ break;
+
+ case KeyEvent.DOM_VK_UP:
+ this.moveFocus(event, -1);
+ break;
+
+ default:
+ break;
+ }
+ });
+ }
+
+ connectedCallback() {
+ const parentDiv = document.createElement("div");
+ parentDiv.classList.add("parent");
+ parentDiv.setAttribute("tabIndex", "0");
+
+ this.includeNode = document.createElement("div");
+ this.includeNode.classList.add("popup-menuitem", "top");
+ this.includeNode.setAttribute("tabindex", "0");
+ this.includeNode.onmouseover = () => {
+ this.focus();
+ };
+ this.includeNode.onkeypress = event => {
+ if (event.keyCode == event.DOM_VK_RETURN) {
+ this.doInclude();
+ }
+ };
+ this.includeNode.onmouseup = () => {
+ this.doInclude();
+ };
+
+ this.excludeNode = document.createElement("div");
+ this.excludeNode.classList.add("popup-menuitem", "bottom");
+ this.excludeNode.setAttribute("tabindex", "0");
+ this.excludeNode.onmouseover = () => {
+ this.focus();
+ };
+ this.excludeNode.onkeypress = event => {
+ if (event.keyCode == event.DOM_VK_RETURN) {
+ this.doExclude();
+ }
+ };
+ this.excludeNode.onmouseup = () => {
+ this.doExclude();
+ };
+
+ this.undoNode = document.createElement("div");
+ this.undoNode.classList.add("popup-menuitem", "undo");
+ this.undoNode.setAttribute("tabindex", "0");
+ this.undoNode.onmouseover = () => {
+ this.focus();
+ };
+ this.undoNode.onkeypress = event => {
+ if (event.keyCode == event.DOM_VK_RETURN) {
+ this.doUndo();
+ }
+ };
+ this.undoNode.onmouseup = () => {
+ this.doUndo();
+ };
+
+ parentDiv.appendChild(this.includeNode);
+ parentDiv.appendChild(this.excludeNode);
+ parentDiv.appendChild(this.undoNode);
+
+ this.appendChild(parentDiv);
+ }
+
+ _getLabel(facetDef, facetValue, groupValue, stringName) {
+ let labelFormat;
+ if (stringName in facetDef.strings) {
+ labelFormat = facetDef.strings[stringName];
+ } else {
+ labelFormat = glodaFacetStrings.GetStringFromName(
+ `glodaFacetView.facets.${stringName}.fallbackLabel`
+ );
+ }
+
+ if (!labelFormat.includes("#1")) {
+ return labelFormat;
+ }
+
+ return labelFormat.replace("#1", facetValue);
+ }
+
+ build(facetDef, facetValue, groupValue) {
+ try {
+ if (groupValue) {
+ this.includeNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "mustMatchLabel"
+ );
+ this.excludeNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "cantMatchLabel"
+ );
+ this.undoNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "mayMatchLabel"
+ );
+ } else {
+ this.includeNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "mustMatchNoneLabel"
+ );
+ this.excludeNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "mustMatchSomeLabel"
+ );
+ this.undoNode.textContent = this._getLabel(
+ facetDef,
+ facetValue,
+ groupValue,
+ "mayMatchAnyLabel"
+ );
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ moveFocus(event, delta) {
+ try {
+ // We probably want something quite generic in the long term, but that
+ // is way too much for now (needs to skip over invisible items, etc)
+ let focused = document.activeElement;
+ if (focused == this.includeNode) {
+ this.excludeNode.focus();
+ } else if (focused == this.excludeNode) {
+ this.includeNode.focus();
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ selectItem(event) {
+ try {
+ let focused = document.activeElement;
+ if (focused == this.includeNode) {
+ this.doInclude();
+ } else if (focused == this.excludeNode) {
+ this.doExclude();
+ } else {
+ this.doUndo();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ show(event, facetNode, barNode) {
+ try {
+ this.node = barNode;
+ this.facetNode = facetNode;
+ let facetDef = facetNode.facetDef;
+ let groupValue = barNode.groupValue;
+ let variety = barNode.getAttribute("variety");
+ let label = barNode.querySelector(".bar-link").textContent;
+ this.build(facetDef, label, groupValue);
+ this.node.setAttribute("selected", "true");
+ const rtl = window.getComputedStyle(this).direction == "rtl";
+ /* We show different menus if we're on an "unselected" facet value,
+ or if we're on a preselected facet value, whether included or
+ excluded. The variety attribute handles that through CSS */
+ this.setAttribute("variety", variety);
+ let rect = barNode.getBoundingClientRect();
+ let x, y;
+ if (event.type == "click") {
+ // center the menu on the mouse click
+ if (rtl) {
+ x = event.pageX + 10;
+ } else {
+ x = event.pageX - 10;
+ }
+ y = Math.max(20, event.pageY - 15);
+ } else {
+ if (rtl) {
+ x = rect.left + rect.width / 2 + 20;
+ } else {
+ x = rect.left + rect.width / 2 - 20;
+ }
+ y = rect.top - 10;
+ }
+ if (rtl) {
+ this.style.left = x - this.getBoundingClientRect().width + "px";
+ } else {
+ this.style.left = x + "px";
+ }
+ this.style.top = y + "px";
+
+ if (variety == "remainder") {
+ // include
+ this.includeNode.focus();
+ } else {
+ // undo
+ this.undoNode.focus();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ hide() {
+ try {
+ this.setAttribute("variety", "invisible");
+ if (this.node) {
+ this.node.removeAttribute("selected");
+ this.node.focus();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ doInclude() {
+ try {
+ this.facetNode.includeFacet(this.node);
+ this.hide();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ doExclude() {
+ this.facetNode.excludeFacet(this.node);
+ this.hide();
+ }
+
+ doUndo() {
+ this.facetNode.undoFacet(this.node);
+ this.hide();
+ }
+ }
+
+ customElements.define("facet-popup-menu", MozFacetPopupMenu);
+
+ /**
+ * MozResultMessage displays an excerpt of a message. Typically these are used in the gloda
+ * results listing, showing the messages that matched.
+ */
+ class MozFacetResultMessage extends HTMLElement {
+ constructor() {
+ super();
+
+ this.addEventListener("mouseover", event => {
+ FacetContext.hoverFacet(
+ FacetContext.fakeResultFaceter,
+ FacetContext.fakeResultAttr,
+ this.message,
+ [this.message]
+ );
+ });
+
+ this.addEventListener("mouseout", event => {
+ FacetContext.unhoverFacet(
+ FacetContext.fakeResultFaceter,
+ FacetContext.fakeResultAttr,
+ this.message,
+ [this.message]
+ );
+ });
+ }
+
+ connectedCallback() {
+ const messageHeader = document.createElement("div");
+
+ const messageLine = document.createElement("div");
+ messageLine.classList.add("message-line");
+
+ const messageMeta = document.createElement("div");
+ messageMeta.classList.add("message-meta");
+
+ this.addressesGroup = document.createElement("div");
+ this.addressesGroup.classList.add("message-addresses-group");
+
+ this.authorGroup = document.createElement("div");
+ this.authorGroup.classList.add("message-author-group");
+
+ this.author = document.createElement("span");
+ this.author.classList.add("message-author");
+
+ this.date = document.createElement("div");
+ this.date.classList.add("message-date");
+
+ this.authorGroup.appendChild(this.author);
+ this.authorGroup.appendChild(this.date);
+ this.addressesGroup.appendChild(this.authorGroup);
+ messageMeta.appendChild(this.addressesGroup);
+ messageLine.appendChild(messageMeta);
+
+ const messageSubjectGroup = document.createElement("div");
+ messageSubjectGroup.classList.add("message-subject-group");
+
+ this.star = document.createElement("span");
+ this.star.classList.add("message-star");
+
+ this.subject = document.createElement("span");
+ this.subject.classList.add("message-subject");
+ this.subject.setAttribute("tabindex", "0");
+ this.subject.setAttribute("role", "link");
+
+ this.tags = document.createElement("span");
+ this.tags.classList.add("message-tags");
+
+ this.recipientsGroup = document.createElement("div");
+ this.recipientsGroup.classList.add("message-recipients-group");
+
+ this.to = document.createElement("span");
+ this.to.classList.add("message-to-label");
+
+ this.recipients = document.createElement("div");
+ this.recipients.classList.add("message-recipients");
+
+ this.recipientsGroup.appendChild(this.to);
+ this.recipientsGroup.appendChild(this.recipients);
+ messageSubjectGroup.appendChild(this.star);
+ messageSubjectGroup.appendChild(this.subject);
+ messageSubjectGroup.appendChild(this.tags);
+ messageSubjectGroup.appendChild(this.recipientsGroup);
+ messageLine.appendChild(messageSubjectGroup);
+ messageHeader.appendChild(messageLine);
+ this.appendChild(messageHeader);
+
+ this.snippet = document.createElement("pre");
+ this.snippet.classList.add("message-body");
+
+ this.attachments = document.createElement("div");
+ this.attachments.classList.add("message-attachments");
+
+ this.appendChild(this.snippet);
+ this.appendChild(this.attachments);
+
+ this.build();
+ }
+
+ /* eslint-disable complexity */
+ build() {
+ let message = this.message;
+
+ let subject = this.subject;
+ // -- eventify
+ subject.onclick = event => {
+ FacetContext.showConversationInTab(this, event.button == 1);
+ };
+ subject.onkeypress = event => {
+ if (Event.keyCode == event.DOM_VK_RETURN) {
+ FacetContext.showConversationInTab(this, event.shiftKey);
+ }
+ };
+
+ // -- Content Poking
+ if (message.subject.trim() == "") {
+ subject.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.result.message.noSubject"
+ );
+ } else {
+ subject.textContent = message.subject;
+ }
+ let authorNode = this.author;
+ authorNode.setAttribute("title", message.from.value);
+ authorNode.textContent = message.from.contact.name;
+ let toNode = this.to;
+ toNode.textContent = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.result.message.toLabel"
+ );
+
+ // this.author.textContent = ;
+ let { makeFriendlyDateAgo } = ChromeUtils.import(
+ "resource:///modules/TemplateUtils.jsm"
+ );
+ this.date.textContent = makeFriendlyDateAgo(message.date);
+
+ // - Recipients
+ try {
+ let recipientsNode = this.recipients;
+ if (message.recipients) {
+ let recipientCount = 0;
+ const MAX_RECIPIENTS = 3;
+ let totalRecipientCount = message.recipients.length;
+ let recipientSeparator = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.message.recipientSeparator"
+ );
+ for (let index in message.recipients) {
+ let recipNode = document.createElement("span");
+ recipNode.setAttribute("class", "message-recipient");
+ recipNode.textContent = message.recipients[index].contact.name;
+ recipientsNode.appendChild(recipNode);
+ recipientCount++;
+ if (recipientCount == MAX_RECIPIENTS) {
+ break;
+ }
+ if (index != totalRecipientCount - 1) {
+ // add separators (usually commas)
+ let sepNode = document.createElement("span");
+ sepNode.setAttribute("class", "message-recipient-separator");
+ sepNode.textContent = recipientSeparator;
+ recipientsNode.appendChild(sepNode);
+ }
+ }
+ if (totalRecipientCount > MAX_RECIPIENTS) {
+ let nOthers = totalRecipientCount - recipientCount;
+ let andNOthers = document.createElement("span");
+ andNOthers.setAttribute("class", "message-recipients-andothers");
+
+ let andOthersLabel = PluralForm.get(
+ nOthers,
+ glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.results.message.andOthers"
+ )
+ ).replace("#1", nOthers);
+
+ andNOthers.textContent = andOthersLabel;
+ recipientsNode.appendChild(andNOthers);
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ // - Starred
+ let starNode = this.star;
+ if (message.starred) {
+ starNode.setAttribute("starred", "true");
+ }
+
+ // - Attachments
+ if (message.attachmentNames) {
+ let attachmentsNode = this.attachments;
+ let imgNode = document.createElement("div");
+ imgNode.setAttribute("class", "message-attachment-icon");
+ attachmentsNode.appendChild(imgNode);
+ for (let attach of message.attachmentNames) {
+ let attachNode = document.createElement("div");
+ attachNode.setAttribute("class", "message-attachment");
+ if (attach.length >= 28) {
+ attach = attach.substring(0, 24) + "…";
+ }
+ attachNode.textContent = attach;
+ attachmentsNode.appendChild(attachNode);
+ }
+ }
+
+ // - Tags
+ let tagsNode = this.tags;
+ if ("tags" in message && message.tags.length) {
+ for (let tag of message.tags) {
+ let tagNode = document.createElement("span");
+ tagNode.setAttribute("class", "message-tag");
+ let color = MailServices.tags.getColorForKey(tag.key);
+ if (color) {
+ let textColor = !TagUtils.isColorContrastEnough(color)
+ ? "white"
+ : "black";
+ tagNode.setAttribute(
+ "style",
+ "color: " + textColor + "; background-color: " + color + ";"
+ );
+ }
+ tagNode.textContent = tag.tag;
+ tagsNode.appendChild(tagNode);
+ }
+ }
+
+ // - Body
+ if (message.indexedBodyText) {
+ let bodyText = message.indexedBodyText;
+
+ let matches = [];
+ if ("stashedColumns" in FacetContext.collection) {
+ let collection;
+ if (
+ "IMCollection" in FacetContext &&
+ message instanceof Gloda.lookupNounDef("im-conversation").clazz
+ ) {
+ collection = FacetContext.IMCollection;
+ } else {
+ collection = FacetContext.collection;
+ }
+ let offsets = collection.stashedColumns[message.id][0];
+ let offsetNums = offsets.split(" ").map(x => parseInt(x));
+ for (let i = 0; i < offsetNums.length; i += 4) {
+ // i is the column index. The indexedBodyText is in the column 0.
+ // Ignore matches for other columns.
+ if (offsetNums[i] != 0) {
+ continue;
+ }
+
+ // i+1 is the term index, indicating which queried term was found.
+ // We can ignore for now...
+
+ // i+2 is the *byte* offset at which the term is in the string.
+ // i+3 is the term's length.
+ matches.push([offsetNums[i + 2], offsetNums[i + 3]]);
+ }
+
+ // Sort the matches by index, just to be sure.
+ // They are probably already sorted, but if they aren't it could
+ // mess things up at the next step.
+ matches.sort((a, b) => a[0] - b[0]);
+
+ // Convert the byte offsets and lengths into character indexes.
+ let charCodeToByteCount = c => {
+ // UTF-8 stores:
+ // - code points below U+0080 on 1 byte,
+ // - code points below U+0800 on 2 bytes,
+ // - code points U+D800 through U+DFFF are UTF-16 surrogate halves
+ // (they indicate that JS has split a 4 bytes UTF-8 character
+ // in two halves of 2 bytes each),
+ // - other code points on 3 bytes.
+ if (c < 0x80) {
+ return 1;
+ }
+ if (c < 0x800 || (c >= 0xd800 && c <= 0xdfff)) {
+ return 2;
+ }
+ return 3;
+ };
+ let byteOffset = 0;
+ let offset = 0;
+ for (let match of matches) {
+ while (byteOffset < match[0]) {
+ byteOffset += charCodeToByteCount(bodyText.charCodeAt(offset++));
+ }
+ match[0] = offset;
+ for (let i = offset; i < offset + match[1]; ++i) {
+ let size = charCodeToByteCount(bodyText.charCodeAt(i));
+ if (size > 1) {
+ match[1] -= size - 1;
+ }
+ }
+ }
+ }
+
+ // how many lines of context we want before the first match:
+ const kContextLines = 2;
+
+ let startIndex = 0;
+ if (matches.length > 0) {
+ // Find where the snippet should begin to show at least the
+ // first match and kContextLines of context before the match.
+ startIndex = matches[0][0];
+ for (let context = kContextLines; context >= 0; --context) {
+ startIndex = bodyText.lastIndexOf("\n", startIndex - 1);
+ if (startIndex == -1) {
+ startIndex = 0;
+ break;
+ }
+ }
+ }
+
+ // start assuming it's just one line that we want to show
+ let idxNewline = -1;
+ let ellipses = "…";
+
+ let maxLineCount = 5;
+ if (startIndex != 0) {
+ // Avoid displaying an ellipses followed by an empty line.
+ while (bodyText[startIndex + 1] == "\n") {
+ ++startIndex;
+ }
+ bodyText = ellipses + bodyText.substring(startIndex);
+ // The first line will only contain the ellipsis as the character
+ // at startIndex is always \n, so we show an additional line.
+ ++maxLineCount;
+ }
+
+ for (
+ let newlineCount = 0;
+ newlineCount < maxLineCount;
+ newlineCount++
+ ) {
+ idxNewline = bodyText.indexOf("\n", idxNewline + 1);
+ if (idxNewline == -1) {
+ ellipses = "";
+ break;
+ }
+ }
+ let snippet = "";
+ if (idxNewline > -1) {
+ snippet = bodyText.substring(0, idxNewline);
+ } else {
+ snippet = bodyText;
+ }
+ if (ellipses) {
+ snippet = snippet.trimRight() + ellipses;
+ }
+
+ let parent = this.snippet;
+ let node = document.createTextNode(snippet);
+ parent.appendChild(node);
+
+ let offset = startIndex ? startIndex - 1 : 0; // The ellipsis takes 1 character.
+ for (let match of matches) {
+ if (idxNewline > -1 && match[0] > startIndex + idxNewline) {
+ break;
+ }
+ let secondNode = node.splitText(match[0] - offset);
+ node = secondNode.splitText(match[1]);
+ offset += match[0] + match[1] - offset;
+ let span = document.createElement("span");
+ span.textContent = secondNode.data;
+ if (!this.firstMatchText) {
+ this.firstMatchText = secondNode.data;
+ }
+ span.setAttribute("class", "message-body-fulltext-match");
+ parent.replaceChild(span, secondNode);
+ }
+ }
+
+ // - Misc attributes
+ if (!message.read) {
+ this.setAttribute("unread", "true");
+ }
+ }
+ }
+
+ customElements.define("facet-result-message", MozFacetResultMessage);
+}