summaryrefslogtreecommitdiffstats
path: root/comm/mail/base/content/glodaFacetView.js
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/base/content/glodaFacetView.js')
-rw-r--r--comm/mail/base/content/glodaFacetView.js1114
1 files changed, 1114 insertions, 0 deletions
diff --git a/comm/mail/base/content/glodaFacetView.js b/comm/mail/base/content/glodaFacetView.js
new file mode 100644
index 0000000000..db8bce8150
--- /dev/null
+++ b/comm/mail/base/content/glodaFacetView.js
@@ -0,0 +1,1114 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * This file provides the global context for the faceting environment. In the
+ * Model View Controller (paradigm), we are the view and the XBL widgets are
+ * the the view and controller.
+ *
+ * Because much of the work related to faceting is not UI-specific, we try and
+ * push as much of it into mailnews/db/gloda/Facet.jsm. In some cases we may
+ * get it wrong and it may eventually want to migrate.
+ */
+
+var { PluralForm } = ChromeUtils.importESModule(
+ "resource://gre/modules/PluralForm.sys.mjs"
+);
+var { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+var { TagUtils } = ChromeUtils.import("resource:///modules/TagUtils.jsm");
+var { Gloda } = ChromeUtils.import("resource:///modules/gloda/GlodaPublic.jsm");
+var { GlodaConstants } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaConstants.jsm"
+);
+var { GlodaSyntheticView } = ChromeUtils.import(
+ "resource:///modules/gloda/GlodaSyntheticView.jsm"
+);
+var { FacetDriver, FacetUtils } = ChromeUtils.import(
+ "resource:///modules/gloda/Facet.jsm"
+);
+
+var glodaFacetStrings = Services.strings.createBundle(
+ "chrome://messenger/locale/glodaFacetView.properties"
+);
+
+/**
+ * Object containing query-explanantion binding methods.
+ */
+const QueryExplanation = {
+ get node() {
+ return document.getElementById("query-explanation");
+ },
+ /**
+ * Indicate that we are based on a fulltext search
+ */
+ setFulltext(aMsgSearcher) {
+ while (this.node.hasChildNodes()) {
+ this.node.lastChild.remove();
+ }
+
+ const spanify = (text, classNames) => {
+ const span = document.createElement("span");
+ span.setAttribute("class", classNames);
+ span.textContent = text;
+ this.node.appendChild(span);
+ return span;
+ };
+
+ const searchLabel = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.search.label2"
+ );
+ spanify(searchLabel, "explanation-fulltext-label");
+
+ const criteriaText = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.constraints.query.fulltext." +
+ (aMsgSearcher.andTerms ? "and" : "or") +
+ "JoinWord"
+ );
+ for (let [iTerm, term] of aMsgSearcher.fulltextTerms.entries()) {
+ if (iTerm) {
+ spanify(criteriaText, "explanation-fulltext-criteria");
+ }
+ spanify(term, "explanation-fulltext-term");
+ }
+ },
+ setQuery(msgQuery) {
+ try {
+ while (this.node.hasChildNodes()) {
+ this.node.lastChild.remove();
+ }
+
+ const spanify = (text, classNames) => {
+ const span = document.createElement("span");
+ span.setAttribute("class", classNames);
+ span.textContent = text;
+ this.node.appendChild(span);
+ return span;
+ };
+
+ let label = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.search.label2"
+ );
+ spanify(label, "explanation-query-label");
+
+ let constraintStrings = [];
+ for (let constraint of msgQuery._constraints) {
+ if (constraint[0] != 1) {
+ // No idea what this is about.
+ return;
+ }
+ if (constraint[1].attributeName == "involves") {
+ let involvesLabel = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.constraints.query.involves.label"
+ );
+ involvesLabel = involvesLabel.replace("#1", constraint[2].value);
+ spanify(involvesLabel, "explanation-query-involves");
+ } else if (constraint[1].attributeName == "tag") {
+ const tagLabel = glodaFacetStrings.GetStringFromName(
+ "glodaFacetView.constraints.query.tagged.label"
+ );
+ const tag = constraint[2];
+ const tagNode = document.createElement("span");
+ const color = MailServices.tags.getColorForKey(tag.key);
+ tagNode.setAttribute("class", "message-tag");
+ if (color) {
+ let textColor = !TagUtils.isColorContrastEnough(color)
+ ? "white"
+ : "black";
+ tagNode.setAttribute(
+ "style",
+ "color: " + textColor + "; background-color: " + color + ";"
+ );
+ }
+ tagNode.textContent = tag.tag;
+ spanify(tagLabel, "explanation-query-tagged");
+ this.node.appendChild(tagNode);
+ }
+ }
+ label = label + constraintStrings.join(", "); // XXX l10n?
+ } catch (e) {
+ console.error(e);
+ }
+ },
+};
+
+/**
+ * Object containing facets binding methods.
+ */
+const UIFacets = {
+ get node() {
+ return document.getElementById("facets");
+ },
+ clearFacets() {
+ while (this.node.hasChildNodes()) {
+ this.node.lastChild.remove();
+ }
+ },
+ addFacet(type, attrDef, args) {
+ let facet;
+
+ if (type === "boolean") {
+ facet = document.createElement("facet-boolean");
+ } else if (type === "boolean-filtered") {
+ facet = document.createElement("facet-boolean-filtered");
+ } else if (type === "discrete") {
+ facet = document.createElement("facet-discrete");
+ } else {
+ facet = document.createElement("div");
+ facet.setAttribute("class", "facetious");
+ }
+
+ facet.attrDef = attrDef;
+ facet.nounDef = attrDef.objectNounDef;
+ facet.setAttribute("type", type);
+
+ for (let key in args) {
+ facet[key] = args[key];
+ }
+
+ facet.setAttribute("name", attrDef.attributeName);
+ this.node.appendChild(facet);
+
+ return facet;
+ },
+};
+
+/**
+ * Represents the active constraints on a singular facet. Singular facets can
+ * only have an inclusive set or an exclusive set, but not both. Non-singular
+ * facets can have both. Because they are different worlds, non-singular gets
+ * its own class, |ActiveNonSingularConstraint|.
+ */
+function ActiveSingularConstraint(aFaceter, aRanged) {
+ this.faceter = aFaceter;
+ this.attrDef = aFaceter.attrDef;
+ this.facetDef = aFaceter.facetDef;
+ this.ranged = Boolean(aRanged);
+ this.clear();
+}
+ActiveSingularConstraint.prototype = {
+ _makeQuery() {
+ // have the faceter make the query and the invert decision for us if it
+ // implements the makeQuery method.
+ if ("makeQuery" in this.faceter) {
+ [this.query, this.invertQuery] = this.faceter.makeQuery(
+ this.groupValues,
+ this.inclusive
+ );
+ return;
+ }
+
+ let query = (this.query = Gloda.newQuery(GlodaConstants.NOUN_MESSAGE));
+ let constraintFunc;
+ // If the facet definition references a queryHelper defined by the noun
+ // type, use that instead of the standard constraint function.
+ if ("queryHelper" in this.facetDef) {
+ constraintFunc =
+ query[this.attrDef.boundName + this.facetDef.queryHelper];
+ } else {
+ constraintFunc =
+ query[
+ this.ranged
+ ? this.attrDef.boundName + "Range"
+ : this.attrDef.boundName
+ ];
+ }
+ constraintFunc.apply(query, this.groupValues);
+
+ this.invertQuery = !this.inclusive;
+ },
+ /**
+ * Adjust the constraint given the incoming faceting constraint desired.
+ * Mainly, if the inclusive flag is the same as what we already have, we
+ * just append the new values to the existing set of values. If it is not
+ * the same, we replace them.
+ *
+ * @returns true if the caller needs to revalidate their understanding of the
+ * constraint because we have flipped whether we are inclusive or
+ * exclusive and have thrown away some constraints as a result.
+ */
+ constrain(aInclusive, aGroupValues) {
+ if (aInclusive == this.inclusive) {
+ this.groupValues = this.groupValues.concat(aGroupValues);
+ this._makeQuery();
+ return false;
+ }
+
+ let needToRevalidate = this.inclusive != null;
+ this.inclusive = aInclusive;
+ this.groupValues = aGroupValues;
+ this._makeQuery();
+
+ return needToRevalidate;
+ },
+ /**
+ * Relax something we previously constrained. Remove it, some might say. It
+ * is possible after relaxing that we will no longer be an active constraint.
+ *
+ * @returns true if we are no longer constrained at all.
+ */
+ relax(aInclusive, aGroupValues) {
+ if (aInclusive != this.inclusive) {
+ throw new Error("You can't relax a constraint that isn't possible.");
+ }
+
+ for (let groupValue of aGroupValues) {
+ let index = this.groupValues.indexOf(groupValue);
+ if (index == -1) {
+ throw new Error("Tried to relax a constraint that was not in force.");
+ }
+ this.groupValues.splice(index, 1);
+ }
+ if (this.groupValues.length == 0) {
+ this.clear();
+ return true;
+ }
+ this._makeQuery();
+
+ return false;
+ },
+ /**
+ * Indicate whether this constraint is actually doing anything anymore.
+ */
+ get isConstrained() {
+ return this.inclusive != null;
+ },
+ /**
+ * Clear the constraint so that the next call to adjust initializes it.
+ */
+ clear() {
+ this.inclusive = null;
+ this.groupValues = null;
+ this.query = null;
+ this.invertQuery = null;
+ },
+ /**
+ * Filter the items against our constraint.
+ */
+ sieve(aItems) {
+ let query = this.query;
+ let expectedResult = !this.invertQuery;
+ return aItems.filter(item => query.test(item) == expectedResult);
+ },
+ isIncludedGroup(aGroupValue) {
+ if (!this.inclusive) {
+ return false;
+ }
+ return this.groupValues.includes(aGroupValue);
+ },
+ isExcludedGroup(aGroupValue) {
+ if (this.inclusive) {
+ return false;
+ }
+ return this.groupValues.includes(aGroupValue);
+ },
+};
+
+function ActiveNonSingularConstraint(aFaceter, aRanged) {
+ this.faceter = aFaceter;
+ this.attrDef = aFaceter.attrDef;
+ this.facetDef = aFaceter.facetDef;
+ this.ranged = Boolean(aRanged);
+
+ this.clear();
+}
+ActiveNonSingularConstraint.prototype = {
+ _makeQuery(aInclusive, aGroupValues) {
+ // have the faceter make the query and the invert decision for us if it
+ // implements the makeQuery method.
+ if ("makeQuery" in this.faceter) {
+ // returns [query, invertQuery] directly
+ return this.faceter.makeQuery(aGroupValues, aInclusive);
+ }
+
+ let query = Gloda.newQuery(GlodaConstants.NOUN_MESSAGE);
+ let constraintFunc;
+ // If the facet definition references a queryHelper defined by the noun
+ // type, use that instead of the standard constraint function.
+ if ("queryHelper" in this.facetDef) {
+ constraintFunc =
+ query[this.attrDef.boundName + this.facetDef.queryHelper];
+ } else {
+ constraintFunc =
+ query[
+ this.ranged
+ ? this.attrDef.boundName + "Range"
+ : this.attrDef.boundName
+ ];
+ }
+ constraintFunc.apply(query, aGroupValues);
+
+ return [query, false];
+ },
+
+ /**
+ * Adjust the constraint given the incoming faceting constraint desired.
+ * Mainly, if the inclusive flag is the same as what we already have, we
+ * just append the new values to the existing set of values. If it is not
+ * the same, we replace them.
+ */
+ constrain(aInclusive, aGroupValues) {
+ let groupIdAttr = this.attrDef.objectNounDef.isPrimitive
+ ? null
+ : this.facetDef.groupIdAttr;
+ let idMap = aInclusive ? this.includedGroupIds : this.excludedGroupIds;
+ let valList = aInclusive
+ ? this.includedGroupValues
+ : this.excludedGroupValues;
+ for (let groupValue of aGroupValues) {
+ let valId =
+ groupIdAttr !== null && groupValue != null
+ ? groupValue[groupIdAttr]
+ : groupValue;
+ idMap[valId] = true;
+ valList.push(groupValue);
+ }
+
+ let [query, invertQuery] = this._makeQuery(aInclusive, valList);
+ if (aInclusive && !invertQuery) {
+ this.includeQuery = query;
+ } else {
+ this.excludeQuery = query;
+ }
+
+ return false;
+ },
+ /**
+ * Relax something we previously constrained. Remove it, some might say. It
+ * is possible after relaxing that we will no longer be an active constraint.
+ *
+ * @returns true if we are no longer constrained at all.
+ */
+ relax(aInclusive, aGroupValues) {
+ let groupIdAttr = this.attrDef.objectNounDef.isPrimitive
+ ? null
+ : this.facetDef.groupIdAttr;
+ let idMap = aInclusive ? this.includedGroupIds : this.excludedGroupIds;
+ let valList = aInclusive
+ ? this.includedGroupValues
+ : this.excludedGroupValues;
+ for (let groupValue of aGroupValues) {
+ let valId =
+ groupIdAttr !== null && groupValue != null
+ ? groupValue[groupIdAttr]
+ : groupValue;
+ if (!(valId in idMap)) {
+ throw new Error("Tried to relax a constraint that was not in force.");
+ }
+ delete idMap[valId];
+
+ let index = valList.indexOf(groupValue);
+ valList.splice(index, 1);
+ }
+
+ if (valList.length == 0) {
+ if (aInclusive) {
+ this.includeQuery = null;
+ } else {
+ this.excludeQuery = null;
+ }
+ } else {
+ let [query, invertQuery] = this._makeQuery(aInclusive, valList);
+ if (aInclusive && !invertQuery) {
+ this.includeQuery = query;
+ } else {
+ this.excludeQuery = query;
+ }
+ }
+
+ return this.includeQuery == null && this.excludeQuery == null;
+ },
+ /**
+ * Indicate whether this constraint is actually doing anything anymore.
+ */
+ get isConstrained() {
+ return this.includeQuery == null && this.excludeQuery == null;
+ },
+ /**
+ * Clear the constraint so that the next call to adjust initializes it.
+ */
+ clear() {
+ this.includeQuery = null;
+ this.includedGroupIds = {};
+ this.includedGroupValues = [];
+
+ this.excludeQuery = null;
+ this.excludedGroupIds = {};
+ this.excludedGroupValues = [];
+ },
+ /**
+ * Filter the items against our constraint.
+ */
+ sieve(aItems) {
+ let includeQuery = this.includeQuery;
+ let excludeQuery = this.excludeQuery;
+ return aItems.filter(
+ item =>
+ (!includeQuery || includeQuery.test(item)) &&
+ (!excludeQuery || !excludeQuery.test(item))
+ );
+ },
+ isIncludedGroup(aGroupValue) {
+ let valId = aGroupValue[this.facetDef.groupIdAttr];
+ return valId in this.includedGroupIds;
+ },
+ isExcludedGroup(aGroupValue) {
+ let valId = aGroupValue[this.facetDef.groupIdAttr];
+ return valId in this.excludedGroupIds;
+ },
+};
+
+var FacetContext = {
+ facetDriver: new FacetDriver(Gloda.lookupNounDef("message"), window),
+
+ /**
+ * The root collection which our active set is a subset of. We hold onto this
+ * for garbage collection reasons, although the tab that owns us should also
+ * be holding on.
+ */
+ _collection: null,
+ set collection(aCollection) {
+ this._collection = aCollection;
+ },
+ get collection() {
+ return this._collection;
+ },
+
+ _sortBy: null,
+ get sortBy() {
+ return this._sortBy;
+ },
+ set sortBy(val) {
+ try {
+ if (val == this._sortBy) {
+ return;
+ }
+ this._sortBy = val;
+ this.build(this._sieveAll());
+ } catch (e) {
+ console.error(e);
+ }
+ },
+ /**
+ * List of the current working set
+ */
+ _activeSet: null,
+ get activeSet() {
+ return this._activeSet;
+ },
+
+ /**
+ * fullSet is a special attribute which is passed a set of items that we're
+ * displaying, but the order of which is determined by the sortBy property.
+ * On setting the fullSet, we compute both sorted lists, and then on getting,
+ * we return the appropriate one.
+ */
+ get fullSet() {
+ return this._sortBy == "-dascore"
+ ? this._relevantSortedItems
+ : this._dateSortedItems;
+ },
+
+ set fullSet(items) {
+ let scores;
+ if (this.searcher && this.searcher.scores) {
+ scores = this.searcher.scores;
+ } else {
+ scores = Gloda.scoreNounItems(items);
+ }
+ let scoredItems = items.map(function (item, index) {
+ return [scores[index], item];
+ });
+ scoredItems.sort((a, b) => b[0] - a[0]);
+ this._relevantSortedItems = scoredItems.map(scoredItem => scoredItem[1]);
+
+ this._dateSortedItems = this._relevantSortedItems
+ .concat()
+ .sort((a, b) => b.date - a.date);
+ },
+
+ initialBuild() {
+ if (this.searcher) {
+ QueryExplanation.setFulltext(this.searcher);
+ } else {
+ QueryExplanation.setQuery(this.collection.query);
+ }
+ // we like to sort them so should clone the list
+ this.faceters = this.facetDriver.faceters.concat();
+
+ this._timelineShown = !Services.prefs.getBoolPref(
+ "gloda.facetview.hidetimeline"
+ );
+
+ this.everFaceted = false;
+ this._activeConstraints = {};
+ if (this.searcher) {
+ let sortByPref = Services.prefs.getIntPref("gloda.facetview.sortby");
+ this._sortBy = sortByPref == 0 || sortByPref == 2 ? "-dascore" : "-date";
+ } else {
+ this._sortBy = "-date";
+ }
+ this.fullSet = this._removeDupes(this._collection.items.concat());
+ if ("IMCollection" in this) {
+ this.fullSet = this.fullSet.concat(this.IMCollection.items);
+ }
+ this.build(this.fullSet);
+ },
+
+ /**
+ * Remove duplicate messages from search results.
+ *
+ * @param aItems the initial set of messages to deduplicate
+ * @returns the subset of those, with duplicates removed.
+ *
+ * Some IMAP servers (here's looking at you, Gmail) will create message
+ * duplicates unbeknownst to the user. We'd like to deal with them earlier
+ * in the pipeline, but that's a bit hard right now. So as a workaround
+ * we'd rather not show them in the Search Results UI. The simplest way
+ * of doing that is just to cull (from the display) messages with have the
+ * Message-ID of a message already displayed.
+ */
+ _removeDupes(aItems) {
+ let deduped = [];
+ let msgIdsSeen = {};
+ for (let item of aItems) {
+ if (item.headerMessageID in msgIdsSeen) {
+ continue;
+ }
+ deduped.push(item);
+ msgIdsSeen[item.headerMessageID] = true;
+ }
+ return deduped;
+ },
+
+ /**
+ * Kick-off a new faceting pass.
+ *
+ * @param aNewSet the set of items to facet.
+ * @param aCallback the callback to invoke when faceting is completed.
+ */
+ build(aNewSet, aCallback) {
+ this._activeSet = aNewSet;
+ this._callbackOnFacetComplete = aCallback;
+ this.facetDriver.go(this._activeSet, this.facetingCompleted, this);
+ },
+
+ /**
+ * Attempt to figure out a reasonable number of rows to limit each facet to
+ * display. While the number will ordinarily be dominated by the maximum
+ * number of rows we believe the user can easily scan, this may also be
+ * impacted by layout concerns (since we want to avoid scrolling).
+ */
+ planLayout() {
+ // XXX arbitrary!
+ this.maxDisplayRows = 8;
+ this.maxMessagesToShow = 10;
+ },
+
+ /**
+ * Clean up the UI in preparation for a new query to come in.
+ */
+ _resetUI() {
+ for (let faceter of this.faceters) {
+ if (faceter.xblNode && !faceter.xblNode.explicit) {
+ faceter.xblNode.remove();
+ }
+ faceter.xblNode = null;
+ faceter.constraint = null;
+ }
+ },
+
+ _groupCountComparator(a, b) {
+ return b.groupCount - a.groupCount;
+ },
+ /**
+ * Tells the UI about all the facets when notified by the |facetDriver| when
+ * it is done faceting everything.
+ */
+ facetingCompleted() {
+ this.planLayout();
+
+ if (!this.everFaceted) {
+ this.everFaceted = true;
+ this.faceters.sort(this._groupCountComparator);
+ for (let faceter of this.faceters) {
+ let attrName = faceter.attrDef.attributeName;
+ let explicitBinding = document.getElementById("facet-" + attrName);
+
+ if (explicitBinding) {
+ explicitBinding.explicit = true;
+ explicitBinding.faceter = faceter;
+ explicitBinding.attrDef = faceter.attrDef;
+ explicitBinding.facetDef = faceter.facetDef;
+ explicitBinding.nounDef = faceter.attrDef.objectNounDef;
+ explicitBinding.orderedGroups = faceter.orderedGroups;
+ // explicit booleans should always be displayed for consistency
+ if (
+ faceter.groupCount >= 1 ||
+ explicitBinding.getAttribute("type").includes("boolean")
+ ) {
+ try {
+ explicitBinding.build(true);
+ } catch (e) {
+ console.error(e);
+ }
+ explicitBinding.removeAttribute("uninitialized");
+ }
+ faceter.xblNode = explicitBinding;
+ continue;
+ }
+
+ // ignore facets that do not vary!
+ if (faceter.groupCount <= 1) {
+ faceter.xblNode = null;
+ continue;
+ }
+
+ faceter.xblNode = UIFacets.addFacet(faceter.type, faceter.attrDef, {
+ faceter,
+ facetDef: faceter.facetDef,
+ orderedGroups: faceter.orderedGroups,
+ maxDisplayRows: this.maxDisplayRows,
+ explicit: false,
+ });
+ }
+ } else {
+ for (let faceter of this.faceters) {
+ // Do not bother with un-displayed facets, or that are locked by a
+ // constraint. But do bother if the widget can be updated without
+ // losing important data.
+ if (
+ !faceter.xblNode ||
+ (faceter.constraint && !faceter.xblNode.canUpdate)
+ ) {
+ continue;
+ }
+
+ // hide things that have 0/1 groups now and are not constrained and not
+ // explicit
+ if (
+ faceter.groupCount <= 1 &&
+ !faceter.constraint &&
+ (!faceter.xblNode.explicit || faceter.type == "date")
+ ) {
+ faceter.xblNode.style.display = "none";
+ } else {
+ // otherwise, update
+ faceter.xblNode.orderedGroups = faceter.orderedGroups;
+ faceter.xblNode.build(false);
+ faceter.xblNode.removeAttribute("style");
+ }
+ }
+ }
+
+ if (!this._timelineShown) {
+ this._hideTimeline(true);
+ }
+
+ this._showResults();
+
+ if (this._callbackOnFacetComplete) {
+ let callback = this._callbackOnFacetComplete;
+ this._callbackOnFacetComplete = null;
+ callback();
+ }
+ },
+
+ _showResults() {
+ let results = document.getElementById("results");
+ let numMessageToShow = Math.min(
+ this.maxMessagesToShow * this._numPages,
+ this._activeSet.length
+ );
+ results.setMessages(this._activeSet.slice(0, numMessageToShow));
+
+ let showLoading = document.getElementById("showLoading");
+ showLoading.style.display = "none"; // Hide spinner, we're done thinking.
+
+ let showEmpty = document.getElementById("showEmpty");
+ let showAll = document.getElementById("gloda-showall");
+ // Check for no messages at all.
+ if (this._activeSet.length == 0) {
+ showEmpty.style.display = "block";
+ showAll.style.display = "none";
+ } else {
+ showEmpty.style.display = "none";
+ showAll.style.display = "block";
+ }
+
+ let showMore = document.getElementById("showMore");
+ showMore.style.display =
+ this._activeSet.length > numMessageToShow ? "block" : "none";
+ },
+
+ showMore() {
+ this._numPages += 1;
+ this._showResults();
+ },
+
+ zoomOut() {
+ let facetDate = document.getElementById("facet-date");
+ this.removeFacetConstraint(
+ facetDate.faceter,
+ true,
+ facetDate.vis.constraints
+ );
+ facetDate.setAttribute("zoomedout", "true");
+ },
+
+ toggleTimeline() {
+ try {
+ this._timelineShown = !this._timelineShown;
+ if (this._timelineShown) {
+ this._showTimeline();
+ } else {
+ this._hideTimeline(false);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ _showTimeline() {
+ let facetDate = document.getElementById("facet-date");
+ if (facetDate.style.display == "none") {
+ facetDate.style.display = "inherit";
+ // Force binding attachment so the transition to the
+ // visible state actually happens.
+ facetDate.getBoundingClientRect();
+ }
+ let listener = () => {
+ // Need to set overflow to visible so that the zoom button
+ // is not cut off at the top, and overflow=hidden causes
+ // the transition to not work as intended.
+ facetDate.removeAttribute("style");
+ };
+ facetDate.addEventListener("transitionend", listener, { once: true });
+ facetDate.removeAttribute("hide");
+ document.getElementById("date-toggle").setAttribute("checked", "true");
+ Services.prefs.setBoolPref("gloda.facetview.hidetimeline", false);
+ },
+
+ _hideTimeline(immediate) {
+ let facetDate = document.getElementById("facet-date");
+ if (immediate) {
+ facetDate.style.display = "none";
+ }
+ facetDate.style.overflow = "hidden";
+ facetDate.setAttribute("hide", "true");
+ document.getElementById("date-toggle").removeAttribute("checked");
+ Services.prefs.setBoolPref("gloda.facetview.hidetimeline", true);
+ },
+
+ _timelineShown: true,
+
+ /** For use in hovering specific results. */
+ fakeResultFaceter: {},
+ /** For use in hovering specific results. */
+ fakeResultAttr: {},
+
+ _numPages: 1,
+ _HOVER_STABILITY_DURATION_MS: 100,
+ _brushedFacet: null,
+ _brushedGroup: null,
+ _brushedItems: null,
+ _brushTimeout: null,
+ hoverFacet(aFaceter, aAttrDef, aGroupValue, aGroupItems) {
+ // bail if we are already brushing this item
+ if (this._brushedFacet == aFaceter && this._brushedGroup == aGroupValue) {
+ return;
+ }
+
+ this._brushedFacet = aFaceter;
+ this._brushedGroup = aGroupValue;
+ this._brushedItems = aGroupItems;
+
+ if (this._brushTimeout != null) {
+ clearTimeout(this._brushTimeout);
+ }
+ this._brushTimeout = setTimeout(
+ this._timeoutHoverWrapper,
+ this._HOVER_STABILITY_DURATION_MS,
+ this
+ );
+ },
+ _timeoutHover() {
+ this._brushTimeout = null;
+ for (let faceter of this.faceters) {
+ if (faceter == this._brushedFacet || !faceter.xblNode) {
+ continue;
+ }
+
+ if (this._brushedItems != null) {
+ faceter.xblNode.brushItems(this._brushedItems);
+ } else {
+ faceter.xblNode.clearBrushedItems();
+ }
+ }
+ },
+ _timeoutHoverWrapper(aThis) {
+ aThis._timeoutHover();
+ },
+ unhoverFacet(aFaceter, aAttrDef, aGroupValue, aGroupItems) {
+ // have we already brushed from some other source already? ignore then.
+ if (this._brushedFacet != aFaceter || this._brushedGroup != aGroupValue) {
+ return;
+ }
+
+ // reuse hover facet to null everyone out
+ this.hoverFacet(null, null, null, null);
+ },
+
+ /**
+ * Maps attribute names to their corresponding |ActiveConstraint|, if they
+ * have one.
+ */
+ _activeConstraints: null,
+ /**
+ * Called by facet bindings when the user does some clicking and wants to
+ * impose a new constraint.
+ *
+ * @param aFaceter The faceter that is the source of this constraint. We
+ * need to know this because once a facet has a constraint attached,
+ * the UI stops updating it.
+ * @param {boolean} aInclusive Is this an inclusive (true) or exclusive
+ * (false) constraint? The constraint instance is the one that deals with
+ * the nuances resulting from this.
+ * @param aGroupValues A list of the group values this constraint covers. In
+ * general, we expect that only one group value will be present in the
+ * list since this method should get called each time the user clicks
+ * something. Previously, we provided support for an "other" case which
+ * covered multiple groupValues so a single click needed to be able to
+ * pass in a list. The "other" case is gone now, but semantically it's
+ * okay for us to support a list.
+ * @param [aRanged] Is it a ranged constraint? (Currently only for dates)
+ * @param [aNukeExisting] Do we need to replace the existing constraint and
+ * re-sieve everything? This currently only happens for dates, where
+ * our display allows a click to actually make our range more generic
+ * than it currently is. (But this only matters if we already have
+ * a date constraint applied.)
+ * @param [aCallback] The callback to call once (re-)faceting has completed.
+ *
+ * @returns true if the caller needs to revalidate because the constraint has
+ * changed in a way other than explicitly requested. This can occur if
+ * a singular constraint flips its inclusive state and throws away
+ * constraints.
+ */
+ addFacetConstraint(
+ aFaceter,
+ aInclusive,
+ aGroupValues,
+ aRanged,
+ aNukeExisting,
+ aCallback
+ ) {
+ let attrName = aFaceter.attrDef.attributeName;
+
+ let constraint;
+ let needToSieveAll = false;
+ if (attrName in this._activeConstraints) {
+ constraint = this._activeConstraints[attrName];
+
+ needToSieveAll = true;
+ if (aNukeExisting) {
+ constraint.clear();
+ }
+ } else {
+ let constraintClass = aFaceter.attrDef.singular
+ ? ActiveSingularConstraint
+ : ActiveNonSingularConstraint;
+ constraint = this._activeConstraints[attrName] = new constraintClass(
+ aFaceter,
+ aRanged
+ );
+ aFaceter.constraint = constraint;
+ }
+ let needToRevalidate = constraint.constrain(aInclusive, aGroupValues);
+
+ // Given our current implementation, we can only be further constraining our
+ // active set, so we can just sieve the existing active set with the
+ // (potentially updated) constraint. In some cases, it would be much
+ // cheaper to use the facet's knowledge about the items in the groups, but
+ // for now let's keep a single code-path for how we refine the active set.
+ this.build(
+ needToSieveAll ? this._sieveAll() : constraint.sieve(this.activeSet),
+ aCallback
+ );
+
+ return needToRevalidate;
+ },
+
+ /**
+ * Remove a constraint previously imposed by addFacetConstraint. The
+ * constraint must still be active, which means you need to pay attention
+ * when |addFacetConstraint| returns true indicating that you need to
+ * revalidate.
+ *
+ * @param aFaceter
+ * @param aInclusive Whether the group values were previously included /
+ * excluded. If you want to remove some values that were included and
+ * some that were excluded then you need to call us once for each case.
+ * @param aGroupValues The list of group values to remove.
+ * @param aCallback The callback to call once all facets have been updated.
+ *
+ * @returns true if the constraint has been completely removed. Under the
+ * current regime, this will likely cause the binding that is calling us
+ * to be rebuilt, so be aware if you are trying to do any cool animation
+ * that might no longer make sense.
+ */
+ removeFacetConstraint(aFaceter, aInclusive, aGroupValues, aCallback) {
+ let attrName = aFaceter.attrDef.attributeName;
+ let constraint = this._activeConstraints[attrName];
+
+ let constraintGone = false;
+
+ if (constraint.relax(aInclusive, aGroupValues)) {
+ delete this._activeConstraints[attrName];
+ aFaceter.constraint = null;
+ constraintGone = true;
+ }
+
+ // we definitely need to re-sieve everybody in this case...
+ this.build(this._sieveAll(), aCallback);
+
+ return constraintGone;
+ },
+
+ /**
+ * Sieve the items from the underlying collection against all constraints,
+ * returning the value.
+ */
+ _sieveAll() {
+ let items = this.fullSet;
+
+ for (let elem in this._activeConstraints) {
+ items = this._activeConstraints[elem].sieve(items);
+ }
+
+ return items;
+ },
+
+ toggleFulltextCriteria() {
+ this.tab.searcher.andTerms = !this.tab.searcher.andTerms;
+ this._resetUI();
+ this.collection = this.tab.searcher.getCollection(this);
+ },
+
+ /**
+ * Show the active message set in a 3-pane tab.
+ */
+ showActiveSetInTab() {
+ let tabmail = this.rootWin.document.getElementById("tabmail");
+ tabmail.openTab("mail3PaneTab", {
+ folderPaneVisible: false,
+ syntheticView: new GlodaSyntheticView({
+ collection: Gloda.explicitCollection(
+ GlodaConstants.NOUN_MESSAGE,
+ this.activeSet
+ ),
+ }),
+ title: this.tab.title,
+ });
+ },
+
+ /**
+ * Show the conversation in a new 3-pane tab.
+ *
+ * @param {glodaFacetBindings.xml#result-message} aResultMessage The
+ * result the user wants to see in more details.
+ * @param {boolean} [aBackground] Whether it should be in the background.
+ */
+ showConversationInTab(aResultMessage, aBackground) {
+ let tabmail = this.rootWin.document.getElementById("tabmail");
+ let message = aResultMessage.message;
+ if (
+ "IMCollection" in this &&
+ message instanceof Gloda.lookupNounDef("im-conversation").clazz
+ ) {
+ tabmail.openTab("chat", {
+ convType: "log",
+ conv: message,
+ searchTerm: aResultMessage.firstMatchText,
+ background: aBackground,
+ });
+ return;
+ }
+ tabmail.openTab("mail3PaneTab", {
+ folderPaneVisible: false,
+ syntheticView: new GlodaSyntheticView({
+ conversation: message.conversation,
+ message,
+ }),
+ title: message.conversation.subject,
+ background: aBackground,
+ });
+ },
+
+ onItemsAdded(aItems, aCollection) {},
+ onItemsModified(aItems, aCollection) {},
+ onItemsRemoved(aItems, aCollection) {},
+ onQueryCompleted(aCollection) {
+ if (
+ this.tab.query.completed &&
+ (!("IMQuery" in this.tab) || this.tab.IMQuery.completed)
+ ) {
+ this.initialBuild();
+ }
+ },
+};
+
+/**
+ * addEventListener betrayals compel us to establish our link with the
+ * outside world from inside. NeilAway suggests the problem might have
+ * been the registration of the listener prior to initiating the load. Which
+ * is odd considering it works for the XUL case, but I could see how that might
+ * differ. Anywho, this works for now and is a delightful reference to boot.
+ */
+function reachOutAndTouchFrame() {
+ let us = window
+ .getInterface(Ci.nsIWebNavigation)
+ .QueryInterface(Ci.nsIDocShellTreeItem);
+
+ FacetContext.rootWin = us.rootTreeItem.domWindow;
+
+ let parentWin = us.parent.domWindow;
+ let aTab = (FacetContext.tab = parentWin.tab);
+ parentWin.tab = null;
+ window.addEventListener("resize", function () {
+ document.getElementById("facet-date").build(true);
+ });
+ // we need to hook the context up as a listener in all cases since
+ // removal notifications are required.
+ if ("searcher" in aTab) {
+ FacetContext.searcher = aTab.searcher;
+ aTab.searcher.listener = FacetContext;
+ if ("IMSearcher" in aTab) {
+ FacetContext.IMSearcher = aTab.IMSearcher;
+ aTab.IMSearcher.listener = FacetContext;
+ }
+ } else {
+ FacetContext.searcher = null;
+ aTab.collection.listener = FacetContext;
+ }
+ FacetContext.collection = aTab.collection;
+ if ("IMCollection" in aTab) {
+ FacetContext.IMCollection = aTab.IMCollection;
+ }
+
+ // if it has already completed, we need to prod things
+ if (
+ aTab.query.completed &&
+ (!("IMQuery" in aTab) || aTab.IMQuery.completed)
+ ) {
+ FacetContext.initialBuild();
+ }
+}
+
+function clickOnBody(event) {
+ if (event.bubbles) {
+ document.querySelector("facet-popup-menu").hide();
+ }
+ return 0;
+}