/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.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
  • 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); }