summaryrefslogtreecommitdiffstats
path: root/comm/mail/modules/QuickFilterManager.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/modules/QuickFilterManager.jsm')
-rw-r--r--comm/mail/modules/QuickFilterManager.jsm1369
1 files changed, 1369 insertions, 0 deletions
diff --git a/comm/mail/modules/QuickFilterManager.jsm b/comm/mail/modules/QuickFilterManager.jsm
new file mode 100644
index 0000000000..b92a5eeea7
--- /dev/null
+++ b/comm/mail/modules/QuickFilterManager.jsm
@@ -0,0 +1,1369 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = [
+ "QuickFilterState",
+ "QuickFilterManager",
+ "MessageTextFilter",
+ "QuickFilterSearchListener",
+];
+
+const { MailServices } = ChromeUtils.import(
+ "resource:///modules/MailServices.jsm"
+);
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+// XXX we need to know whether the gloda indexer is enabled for upsell reasons,
+// but this should really just be exposed on the main Gloda public interface.
+// we need to be able to create gloda message searcher instances for upsells:
+const lazy = {};
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ GlodaIndexer: "resource:///modules/gloda/GlodaIndexer.jsm",
+ GlodaMsgSearcher: "resource:///modules/gloda/GlodaMsgSearcher.jsm",
+ TagUtils: "resource:///modules/TagUtils.jsm",
+});
+
+/**
+ * Shallow object copy.
+ */
+function shallowObjCopy(obj) {
+ let newObj = {};
+ for (let key in obj) {
+ newObj[key] = obj[key];
+ }
+ return newObj;
+}
+
+/**
+ * Should the filter be visible when there's no previous state to propagate it
+ * from? The idea is that when session persistence is working this should only
+ * ever affect the first time Thunderbird is started up. Although opening
+ * additional 3-panes will likely trigger this unless we go out of our way to
+ * implement propagation across those boundaries (and we're not).
+ */
+var FILTER_VISIBILITY_DEFAULT = true;
+
+/**
+ * Represents the state of a quick filter bar. This mainly decorates the
+ * manipulation of the filter states with support of tracking the filter most
+ * recently manipulated so we can maintain a very limited undo stack of sorts.
+ */
+function QuickFilterState(aTemplateState, aJsonedState) {
+ if (aJsonedState) {
+ this.filterValues = aJsonedState.filterValues;
+ this.visible = aJsonedState.visible;
+ } else if (aTemplateState) {
+ this.filterValues = QuickFilterManager.propagateValues(
+ aTemplateState.filterValues
+ );
+ this.visible = aTemplateState.visible;
+ } else {
+ this.filterValues = QuickFilterManager.getDefaultValues();
+ this.visible = FILTER_VISIBILITY_DEFAULT;
+ }
+ this._lastFilterAttr = null;
+}
+QuickFilterState.prototype = {
+ /**
+ * Maps filter names to their current states. We rely on QuickFilterManager
+ * to do most of the interesting manipulation of this value.
+ */
+ filterValues: null,
+ /**
+ * Is the filter bar visible? Always inherited from the template regardless
+ * of stickyness.
+ */
+ visible: null,
+
+ /**
+ * Get a filter state and update lastFilterAttr appropriately. This is
+ * intended for use when the filter state is a rich object whose state
+ * cannot be updated just by clobbering as provided by |setFilterValue|.
+ *
+ * @param aName The name of the filter we are retrieving.
+ * @param [aNoChange=false] Is this actually a change for the purposes of
+ * lastFilterAttr purposes?
+ */
+ getFilterValue(aName, aNoChange) {
+ if (!aNoChange) {
+ this._lastFilterAttr = aName;
+ }
+ return this.filterValues[aName];
+ },
+
+ /**
+ * Set a filter state and update lastFilterAttr appropriately.
+ *
+ * @param aName The name of the filter we are setting.
+ * @param aValue The value to set; null/undefined implies deletion.
+ * @param [aNoChange=false] Is this actually a change for the purposes of
+ * lastFilterAttr purposes?
+ */
+ setFilterValue(aName, aValue, aNoChange) {
+ if (aValue == null) {
+ delete this.filterValues[aName];
+ return;
+ }
+
+ this.filterValues[aName] = aValue;
+ if (!aNoChange) {
+ this._lastFilterAttr = aName;
+ }
+ },
+
+ /**
+ * Track the last filter that was affirmatively applied. If you hit escape
+ * and this value is non-null, we clear the referenced filter constraint.
+ * If you hit escape and the value is null, we clear all filters.
+ */
+ _lastFilterAttr: null,
+
+ /**
+ * The user hit escape; based on _lastFilterAttr and whether there are any
+ * applied filters, change our constraints. First press clears the last
+ * added constraint (if any), second press (or if no last constraint) clears
+ * the state entirely.
+ *
+ * @returns true if we relaxed the state, false if there was nothing to relax.
+ */
+ userHitEscape() {
+ if (this._lastFilterAttr) {
+ // it's possible the UI state the last attribute has already been cleared,
+ // in which case we want to fall through...
+ if (
+ QuickFilterManager.clearFilterValue(
+ this._lastFilterAttr,
+ this.filterValues
+ )
+ ) {
+ this._lastFilterAttr = null;
+ return true;
+ }
+ }
+
+ return QuickFilterManager.clearAllFilterValues(this.filterValues);
+ },
+
+ /**
+ * Clear the state without going through any undo-ish steps like
+ * |userHitEscape| tries to do.
+ */
+ clear() {
+ QuickFilterManager.clearAllFilterValues(this.filterValues);
+ },
+
+ /**
+ * Create the search terms appropriate to the current filter states.
+ */
+ createSearchTerms(aTermCreator) {
+ return QuickFilterManager.createSearchTerms(
+ this.filterValues,
+ aTermCreator
+ );
+ },
+
+ persistToObj() {
+ return {
+ filterValues: this.filterValues,
+ visible: this.visible,
+ };
+ },
+};
+
+/**
+ * An nsIMsgSearchNotify listener wrapper to facilitate faceting of messages
+ * being returned by a search. We have to use a listener because the
+ * nsMsgDBView includes presentation logic and unless we force all of its
+ * results to be fully expanded (and dummy headers ignored), we can't get
+ * at all the messages reliably.
+ *
+ * We need to provide a wrapper so that:
+ * - We can provide better error handling support.
+ * - We can provide better GC support.
+ * - We can ensure the right life-cycle stuff happens (unregister ourselves as
+ * a listener, namely.)
+ *
+ * It is nice that we have a wrapper so that:
+ * - We can provide context to the thing we are calling that it does not need
+ * to maintain.
+ *
+ * The listener should implement the following methods:
+ *
+ * - function onSearchStart(aCurState) returning aScratch.
+ * This function should initialize the scratch object that will be passed to
+ * onSearchMessage and onSearchDone. This is an attempt to provide a
+ * friendly API that provides debugging support by dumping the state of
+ * said object when things go wrong.
+ *
+ * - function onSearchMessage(aScratch, aMsgHdr, aFolder)
+ * Processes messages reported as search hits. Its only context is the
+ * object you returned from onSearchStart. Take the hint and try and keep
+ * this method efficient! We will catch all exceptions for you and report
+ * errors. We will also handle forcing GCs as appropriate.
+ *
+ * - function onSearchDone(aCurState, aScratch, aSuccess) returning
+ * [new state for your filter, should call reflectInDOM, should treat the
+ * state as if it is a result of user action].
+ * This ends up looking exactly the same as the postFilterProcess handler
+ *
+ * @param aFilterer The QuickFilterState instance.
+ * @param aListener The thing on which we invoke methods.
+ */
+function QuickFilterSearchListener(
+ aViewWrapper,
+ aFilterer,
+ aFilterDef,
+ aListener,
+ aMuxer
+) {
+ this.filterer = aFilterer;
+ this.filterDef = aFilterDef;
+ this.listener = aListener;
+ this.muxer = aMuxer;
+
+ this.session = aViewWrapper.search.session;
+
+ this.scratch = null;
+ this.count = 0;
+ this.started = false;
+
+ this.session.registerListener(this, Ci.nsIMsgSearchSession.allNotifications);
+}
+QuickFilterSearchListener.prototype = {
+ onNewSearch() {
+ this.started = true;
+ let curState =
+ this.filterDef.name in this.filterer.filterValues
+ ? this.filterer.filterValues[this.filterDef.name]
+ : null;
+ this.scratch = this.listener.onSearchStart(curState);
+ },
+
+ onSearchHit(aMsgHdr, aFolder) {
+ // GC sanity demands that we trigger a GC if we have seen a large number
+ // of headers. Because we are driven by the search mechanism which likes
+ // to time-slice when it has a lot of messages on its plate, it is
+ // conceivable something else may trigger a GC for us. Unfortunately,
+ // we can't guarantee it, as XPConnect does not inform memory pressure,
+ // so it's us to stop-gap it.
+ this.count++;
+ if (!(this.count % 4096)) {
+ Cu.forceGC();
+ }
+
+ try {
+ this.listener.onSearchMessage(this.scratch, aMsgHdr, aFolder);
+ } catch (ex) {
+ console.error(ex);
+ }
+ },
+
+ onSearchDone(aStatus) {
+ // it's possible we will see the tail end of an existing search. ignore.
+ if (!this.started) {
+ return;
+ }
+
+ this.session.unregisterListener(this);
+
+ let curState =
+ this.filterDef.name in this.filterer.filterValues
+ ? this.filterer.filterValues[this.filterDef.name]
+ : null;
+ let [newState, update, treatAsUserAction] = this.listener.onSearchDone(
+ curState,
+ this.scratch,
+ aStatus
+ );
+
+ this.filterer.setFilterValue(
+ this.filterDef.name,
+ newState,
+ !treatAsUserAction
+ );
+ if (update) {
+ this.muxer.reflectFiltererState(this.filterDef.name);
+ }
+ },
+};
+
+/**
+ * Extensible mechanism for defining filters for the quick filter bar. This
+ * is the spiritual successor to the mailViewManager and quickSearchManager.
+ *
+ * The manager includes and requires UI-relevant metadata for use by its
+ * counterparts in quickFilterBar.js. New filters are expected to contribute
+ * DOM nodes to the overlay and tell us about them using their id during
+ * registration.
+ *
+ * We support two types of filtery things.
+ * - Filters via defineFilter.
+ * - Text filters via defineTextFilter. These always take the filter text as
+ * a parameter.
+ *
+ * If you are an adventurous extension developer and want to add a magic
+ * text filter that does the whole "from:bob to:jim subject:shoes" what you
+ * will want to do is register a normal filter and collapse the normal text
+ * filter text-box. You add your own text box, etc.
+ */
+var QuickFilterManager = {
+ /**
+ * List of filter definitions, potentially prioritized.
+ */
+ filterDefs: [],
+ /**
+ * Keys are filter definition names, values are the filter defs.
+ */
+ filterDefsByName: {},
+ /**
+ * The DOM id of the text widget that should get focused when the user hits
+ * control-f or the equivalent. This is here so it can get clobbered.
+ */
+ textBoxDomId: null,
+
+ /**
+ * Define a new filter.
+ *
+ * Filter states must always be JSON serializable. A state of undefined means
+ * that we are not persisting any state for your filter.
+ *
+ * @param {string} aFilterDef.name The name of your filter. This is the name
+ * of the attribute we cram your state into the state dictionary as, so
+ * the key thing is that it doesn't conflict with other id's.
+ * @param {string} aFilterDef.domId The id of the DOM node that you have
+ * overlaid into the quick filter bar.
+ * @param {function(aTermCreator, aTerms, aState)} aFilterDef.appendTerms
+ * The function to invoke to contribute your terms to the list of
+ * search terms in aTerms. Your function will not be invoked if you do
+ * not have any currently persisted state (as is the case if null or
+ * undefined was set). If you have nothing to add, then don't do
+ * anything. If you do add terms, the first term you add needs to have
+ * the booleanAnd flag set to true. You may optionally return a listener
+ * that complies with the documentation on QuickFilterSearchListener if
+ * you want to process all of the messages returned by the filter; doing
+ * so is not cheap, so don't do that lightly. (Tag faceting uses this.)
+ * @param {function()} [aFilterDef.getDefaults] Function that returns the
+ * default state for the filter. If the function is not defined or the
+ * returned value is == undefined/null, no state is set.
+ * @param {function(aTemplState, aSticky)} [aFilterDef.propagateState] A
+ * function that takes the state from another QuickFilterState instance
+ * for this definition and propagates it to a new state which it returns.
+ * You would use this to keep the 'sticky' bits of state that you want to
+ * persist between folder changes and when new tabs are opened. The
+ * aSticky argument tells you if the user wants all the filters still
+ * applied or not. When false, the idea is you might keep things like
+ * which text fields to filter on, but not the text to filter. When true,
+ * you would keep the text to filter on too. Return undefined if you do
+ * not want any state stored in the new filter state. If you do not
+ * define this function and aSticky would be true, we will propagate your
+ * state verbatim; accordingly functions using rich object state must
+ * implement this method.
+ * @param {function(aState)} [aFilterDef.clearState] Function to reset the
+ * the filter's value for the given state, returning a tuple of the new
+ * state and a boolean flag indicating whether there was actually state to
+ * clear. This is used when the user decides to reset the state of the
+ * filter bar or (just one specific filter). If omitted, we just delete
+ * the filter state entirely, so you only need to define this if you have
+ * some sticky meta-state you want to maintain. Return undefined for the
+ * state value if you do not need any state kept around.
+ * @param {function(aDocument, aMuxer, aNode)} [aFilterDef.domBindExtra]
+ * Function invoked at initial UI binding of the quick filter bar after
+ * we add a command listener to whatever is identified by domId. If you
+ * have additional widgets to hook up, this is where you do it. aDocument
+ * and aMuxer are provided to assist in this endeavor. Use aMuxer's
+ * getFilterValueForMutation/setFilterValue/updateSearch methods from any
+ * event handlers you register.
+ * @param {function(aState, aNode, aEvent, aDocument)} [aFilterDef.onCommand]
+ * If omitted, the default handler assumes your widget has a "checked"
+ * state that should set your state value to true when checked and delete
+ * the state when unchecked. Implement this function if that is not what
+ * you need. The function should return a tuple of [new state, should
+ * update the search] as its result.
+ * @param {function(aDomNode, aFilterValue, aDoc, aMuxer, aCallId)}
+ * [aFilterDef.reflectInDOM]
+ * If omitted, we assume the widget referenced by domId has a checked
+ * attribute and assign the filter value coerced to a boolean to the
+ * checked attribute. Otherwise we call your function and it's up to you
+ * to reflect your state. aDomNode is the node referred to by domId.
+ * This function will be called when the tab changes, folder changes, or
+ * if we called postFilterProcess and you returned a value != undefined.
+ * @param {function(aState, aViewWrapper, aFiltering)}
+ * [aFilterDef.postFilterProcess]
+ * Invoked after all of the message headers for the view have been
+ * displayed, allowing your code to perform some kind of faceting or other
+ * clever logic. Return a tuple of [new state, should call reflectInDOM,
+ * should treat as if the user modified the state]. We call this _even
+ * when there is no filter_ applied. We tell you what's happening via
+ * aFiltering; true means we have applied some terms, false means not.
+ * It's vitally important that you do not just facet things willy nilly
+ * unless there is expected user payoff and they opted in. Our tagging UI
+ * only facets when the user clicked the tag facet. If you write an
+ * extension that provides really sweet visualizations or something like
+ * that and the user installs you knowing what's what, that is also cool,
+ * we just can't do it in core for now.
+ */
+ defineFilter(aFilterDef) {
+ this.filterDefs.push(aFilterDef);
+ this.filterDefsByName[aFilterDef.name] = aFilterDef;
+ },
+
+ /**
+ * Remove a filter from existence by name. This is for extensions to disable
+ * existing filters and not a dynamic jetpack-like lifecycle. It falls to
+ * the code calling killFilter to deal with the DOM nodes themselves for now.
+ *
+ * @param aName The name of the filter to kill.
+ */
+ killFilter(aName) {
+ let filterDef = this.filterDefsByName[aName];
+ this.filterDefs.splice(this.filterDefs.indexOf(filterDef), 1);
+ delete this.filterDefsByName[aName];
+ },
+
+ /**
+ * Propagate values from an existing state into a new state based on
+ * propagation rules. For use by QuickFilterState.
+ *
+ * @param aTemplValues A set of existing filterValues.
+ * @returns The new filterValues state.
+ */
+ propagateValues(aTemplValues) {
+ let values = {};
+ let sticky = "sticky" in aTemplValues ? aTemplValues.sticky : false;
+
+ for (let filterDef of this.filterDefs) {
+ if ("propagateState" in filterDef) {
+ let curValue =
+ filterDef.name in aTemplValues
+ ? aTemplValues[filterDef.name]
+ : undefined;
+ let newValue = filterDef.propagateState(curValue, sticky);
+ if (newValue != null) {
+ values[filterDef.name] = newValue;
+ }
+ } else if (sticky) {
+ // Always propagate the value if sticky and there was no handler.
+ if (filterDef.name in aTemplValues) {
+ values[filterDef.name] = aTemplValues[filterDef.name];
+ }
+ }
+ }
+
+ return values;
+ },
+ /**
+ * Get the set of default filterValues for the current set of defined filters.
+ *
+ * @returns Thew new filterValues state.
+ */
+ getDefaultValues() {
+ let values = {};
+ for (let filterDef of this.filterDefs) {
+ if ("getDefaults" in filterDef) {
+ let newValue = filterDef.getDefaults();
+ if (newValue != null) {
+ values[filterDef.name] = newValue;
+ }
+ }
+ }
+ return values;
+ },
+
+ /**
+ * Reset the state of a single filter given the provided values.
+ *
+ * @returns true if we actually cleared some state, false if there was nothing
+ * to clear.
+ */
+ clearFilterValue(aFilterName, aValues) {
+ let filterDef = this.filterDefsByName[aFilterName];
+ if (!("clearState" in filterDef)) {
+ if (aFilterName in aValues) {
+ delete aValues[aFilterName];
+ return true;
+ }
+ return false;
+ }
+
+ let curValue = aFilterName in aValues ? aValues[aFilterName] : undefined;
+ // Yes, we want to call it to clear its state even if it has no state.
+ let [newValue, didClear] = filterDef.clearState(curValue);
+ if (newValue != null) {
+ aValues[aFilterName] = newValue;
+ } else {
+ delete aValues[aFilterName];
+ }
+ return didClear;
+ },
+
+ /**
+ * Reset the state of all filters given the provided values.
+ *
+ * @returns true if we actually cleared something, false if there was nothing
+ * to clear.
+ */
+ clearAllFilterValues(aFilterValues) {
+ let didClearSomething = false;
+ for (let filterDef of this.filterDefs) {
+ if (this.clearFilterValue(filterDef.name, aFilterValues)) {
+ didClearSomething = true;
+ }
+ }
+ return didClearSomething;
+ },
+
+ /**
+ * Populate and return a list of search terms given the provided state.
+ *
+ * We only invoke appendTerms on filters that have state in aFilterValues,
+ * as per the contract.
+ */
+ createSearchTerms(aFilterValues, aTermCreator) {
+ let searchTerms = [],
+ listeners = [];
+ for (let filterName in aFilterValues) {
+ let filterValue = aFilterValues[filterName];
+ let filterDef = this.filterDefsByName[filterName];
+ try {
+ let listener = filterDef.appendTerms(
+ aTermCreator,
+ searchTerms,
+ filterValue
+ );
+ if (listener) {
+ listeners.push([listener, filterDef]);
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ return searchTerms.length ? [searchTerms, listeners] : [null, listeners];
+ },
+};
+
+/**
+ * Meta-filter, just handles whether or not things are sticky.
+ */
+QuickFilterManager.defineFilter({
+ name: "sticky",
+ domId: "qfb-sticky",
+ appendTerms(aTermCreator, aTerms, aFilterValue) {},
+ /**
+ * This should not cause an update, otherwise default logic.
+ */
+ onCommand(aState, aNode, aEvent, aDocument) {
+ let checked = aNode.pressed;
+ return [checked, false];
+ },
+});
+
+/**
+ * true: must be unread, false: must be read.
+ */
+QuickFilterManager.defineFilter({
+ name: "unread",
+ domId: "qfb-unread",
+ menuItemID: "quickFilterButtonsContextUnreadToggle",
+ appendTerms(aTermCreator, aTerms, aFilterValue) {
+ let term, value;
+ term = aTermCreator.createTerm();
+ term.attrib = Ci.nsMsgSearchAttrib.MsgStatus;
+ value = term.value;
+ value.attrib = term.attrib;
+ value.status = Ci.nsMsgMessageFlags.Read;
+ term.value = value;
+ term.op = aFilterValue ? Ci.nsMsgSearchOp.Isnt : Ci.nsMsgSearchOp.Is;
+ term.booleanAnd = true;
+ aTerms.push(term);
+ },
+});
+
+/**
+ * true: must be starred, false: must not be starred.
+ */
+QuickFilterManager.defineFilter({
+ name: "starred",
+ domId: "qfb-starred",
+ menuItemID: "quickFilterButtonsContextStarredToggle",
+ appendTerms(aTermCreator, aTerms, aFilterValue) {
+ let term, value;
+ term = aTermCreator.createTerm();
+ term.attrib = Ci.nsMsgSearchAttrib.MsgStatus;
+ value = term.value;
+ value.attrib = term.attrib;
+ value.status = Ci.nsMsgMessageFlags.Marked;
+ term.value = value;
+ term.op = aFilterValue ? Ci.nsMsgSearchOp.Is : Ci.nsMsgSearchOp.Isnt;
+ term.booleanAnd = true;
+ aTerms.push(term);
+ },
+});
+
+/**
+ * true: sender must be in a local address book, false: sender must not be.
+ */
+QuickFilterManager.defineFilter({
+ name: "addrBook",
+ domId: "qfb-inaddrbook",
+ menuItemID: "quickFilterButtonsContextInaddrbookToggle",
+ appendTerms(aTermCreator, aTerms, aFilterValue) {
+ let term, value;
+ let firstBook = true;
+ term = null;
+ for (let addrbook of MailServices.ab.directories) {
+ if (!addrbook.isRemote) {
+ term = aTermCreator.createTerm();
+ term.attrib = Ci.nsMsgSearchAttrib.Sender;
+ value = term.value;
+ value.attrib = term.attrib;
+ value.str = addrbook.URI;
+ term.value = value;
+ term.op = aFilterValue
+ ? Ci.nsMsgSearchOp.IsInAB
+ : Ci.nsMsgSearchOp.IsntInAB;
+ // It's an AND if we're the first book (so the boolean affects the
+ // group as a whole.)
+ // It's the negation of whether we're filtering otherwise; demorgans.
+ term.booleanAnd = firstBook || !aFilterValue;
+ term.beginsGrouping = firstBook;
+ aTerms.push(term);
+ firstBook = false;
+ }
+ }
+ if (term) {
+ term.endsGrouping = true;
+ }
+ },
+});
+
+/**
+ * It's a tag filter that sorta facets! Stealing gloda's thunder! Woo!
+ *
+ * Filter on message tags? Meanings:
+ * - true: Yes, must have at least one tag on it.
+ * - false: No, no tags on it!
+ * - dictionary where keys are tag keys and values are tri-state with null
+ * meaning don't constraint, true meaning yes should be present, false
+ * meaning no, don't be present
+ */
+var TagFacetingFilter = {
+ name: "tags",
+ domId: "qfb-tags",
+ menuItemID: "quickFilterButtonsContextTagsToggle",
+ callID: "",
+
+ /**
+ * @returns true if the constaint is only on has tags/does not have tags,
+ * false if there are specific tag constraints in play.
+ */
+ isSimple(aFilterValue) {
+ // it's the simple case if the value is just a boolean
+ if (typeof aFilterValue != "object") {
+ return true;
+ }
+ // but also if the object contains no non-null values
+ let simpleCase = true;
+ for (let key in aFilterValue.tags) {
+ let value = aFilterValue.tags[key];
+ if (value !== null) {
+ simpleCase = false;
+ break;
+ }
+ }
+ return simpleCase;
+ },
+
+ /**
+ * Because we support both inclusion and exclusion we can produce up to two
+ * groups. One group for inclusion, one group for exclusion. To get listed
+ * the message must have any/all of the tags marked for inclusion,
+ * (depending on mode), but it cannot have any of the tags marked for
+ * exclusion.
+ */
+ appendTerms(aTermCreator, aTerms, aFilterValue) {
+ if (aFilterValue == null) {
+ return null;
+ }
+
+ let term, value;
+
+ // just the true/false case
+ if (this.isSimple(aFilterValue)) {
+ term = aTermCreator.createTerm();
+ term.attrib = Ci.nsMsgSearchAttrib.Keywords;
+ value = term.value;
+ value.str = "";
+ term.value = value;
+ term.op = aFilterValue
+ ? Ci.nsMsgSearchOp.IsntEmpty
+ : Ci.nsMsgSearchOp.IsEmpty;
+ term.booleanAnd = true;
+ aTerms.push(term);
+
+ // we need to perform faceting if the value is literally true.
+ if (aFilterValue === true) {
+ return this;
+ }
+ } else {
+ let firstIncludeClause = true,
+ firstExcludeClause = true;
+ let lastIncludeTerm = null;
+ term = null;
+
+ let excludeTerms = [];
+
+ let mode = aFilterValue.mode;
+ for (let key in aFilterValue.tags) {
+ let shouldFilter = aFilterValue.tags[key];
+ if (shouldFilter !== null) {
+ term = aTermCreator.createTerm();
+ term.attrib = Ci.nsMsgSearchAttrib.Keywords;
+ value = term.value;
+ value.attrib = term.attrib;
+ value.str = key;
+ term.value = value;
+ if (shouldFilter) {
+ term.op = Ci.nsMsgSearchOp.Contains;
+ // AND for the group. Inside the group we also want AND if the
+ // mode is set to "All of".
+ term.booleanAnd = firstIncludeClause || mode === "AND";
+ term.beginsGrouping = firstIncludeClause;
+ aTerms.push(term);
+ firstIncludeClause = false;
+ lastIncludeTerm = term;
+ } else {
+ term.op = Ci.nsMsgSearchOp.DoesntContain;
+ // you need to not include all of the tags marked excluded.
+ term.booleanAnd = true;
+ term.beginsGrouping = firstExcludeClause;
+ excludeTerms.push(term);
+ firstExcludeClause = false;
+ }
+ }
+ }
+ if (lastIncludeTerm) {
+ lastIncludeTerm.endsGrouping = true;
+ }
+
+ // if we have any exclude terms:
+ // - we might need to add a "has a tag" clause if there were no explicit
+ // inclusions.
+ // - extend the exclusions list in.
+ if (excludeTerms.length) {
+ // (we need to add has a tag)
+ if (!lastIncludeTerm) {
+ term = aTermCreator.createTerm();
+ term.attrib = Ci.nsMsgSearchAttrib.Keywords;
+ value = term.value;
+ value.str = "";
+ term.value = value;
+ term.op = Ci.nsMsgSearchOp.IsntEmpty;
+ term.booleanAnd = true;
+ aTerms.push(term);
+ }
+
+ // (extend in the exclusions)
+ excludeTerms[excludeTerms.length - 1].endsGrouping = true;
+ aTerms.push.apply(aTerms, excludeTerms);
+ }
+ }
+ return null;
+ },
+
+ onSearchStart(aCurState) {
+ // this becomes aKeywordMap; we want to start with an empty one
+ return {};
+ },
+ onSearchMessage(aKeywordMap, aMsgHdr, aFolder) {
+ let keywords = aMsgHdr.getStringProperty("keywords");
+ let keywordList = keywords.split(" ");
+ for (let iKeyword = 0; iKeyword < keywordList.length; iKeyword++) {
+ let keyword = keywordList[iKeyword];
+ aKeywordMap[keyword] = null;
+ }
+ },
+ onSearchDone(aCurState, aKeywordMap, aStatus) {
+ // we are an async operation; if the user turned off the tag facet already,
+ // then leave that state intact...
+ if (aCurState == null) {
+ return [null, false, false];
+ }
+
+ // only propagate things that are actually tags though!
+ let outKeyMap = { tags: {} };
+ let tags = MailServices.tags.getAllTags();
+ let tagCount = tags.length;
+ for (let iTag = 0; iTag < tagCount; iTag++) {
+ let tag = tags[iTag];
+
+ if (tag.key in aKeywordMap) {
+ outKeyMap.tags[tag.key] = aKeywordMap[tag.key];
+ }
+ }
+ return [outKeyMap, true, false];
+ },
+
+ /**
+ * We need to clone our state if it's an object to avoid bad sharing.
+ */
+ propagateState(aOld, aSticky) {
+ // stay disabled when disabled, get disabled when not sticky
+ if (aOld == null || !aSticky) {
+ return null;
+ }
+ if (this.isSimple(aOld)) {
+ // Could be an object, need to convert.
+ return !!aOld;
+ }
+ return shallowObjCopy(aOld);
+ },
+
+ /**
+ * Default behaviour but:
+ * - We collapse our expando if we get unchecked.
+ * - We want to initiate a faceting pass if we just got checked.
+ */
+ onCommand(aState, aNode, aEvent, aDocument) {
+ let checked;
+ if (aNode.tagName == "button") {
+ checked = aNode.pressed ? true : null;
+ } else {
+ checked = aNode.hasAttribute("checked") ? true : null;
+ }
+
+ if (!checked) {
+ aDocument.getElementById("quickFilterBarTagsContainer").hidden = true;
+ }
+
+ // return ourselves if we just got checked to have
+ // onSearchStart/onSearchMessage/onSearchDone get to do their thing.
+ return [checked, true];
+ },
+
+ domBindExtra(aDocument, aMuxer, aNode) {
+ // Tag filtering mode menu (All of/Any of)
+ function commandHandler(aEvent) {
+ let filterValue = aMuxer.getFilterValueForMutation(
+ TagFacetingFilter.name
+ );
+ filterValue.mode = aEvent.target.value;
+ aMuxer.updateSearch();
+ }
+ aDocument
+ .getElementById("qfb-boolean-mode")
+ .addEventListener("ValueChange", commandHandler);
+ },
+
+ reflectInDOM(aNode, aFilterValue, aDocument, aMuxer, aCallId) {
+ if (aCallId !== null && aCallId == "menuItem") {
+ aFilterValue
+ ? aNode.setAttribute("checked", aFilterValue)
+ : aNode.removeAttribute("checked");
+ } else {
+ aNode.pressed = aFilterValue;
+ }
+ if (aFilterValue != null && typeof aFilterValue == "object") {
+ this._populateTagBar(aFilterValue, aDocument, aMuxer);
+ } else {
+ aDocument.getElementById("quickFilterBarTagsContainer").hidden = true;
+ }
+ },
+
+ _populateTagBar(aState, aDocument, aMuxer) {
+ let tagbar = aDocument.getElementById("quickFilterBarTagsContainer");
+ let keywordMap = aState.tags;
+
+ // If we have a mode stored use that. If we don't have a mode, then update
+ // our state to agree with what the UI is currently displaying;
+ // this will happen for fresh profiles.
+ let qbm = aDocument.getElementById("qfb-boolean-mode");
+ if (aState.mode) {
+ qbm.value = aState.mode;
+ } else {
+ aState.mode = qbm.value;
+ }
+
+ function clickHandler(aEvent) {
+ let tagKey = this.getAttribute("value");
+ let state = aMuxer.getFilterValueForMutation(TagFacetingFilter.name);
+ state.tags[tagKey] = this.pressed ? true : null;
+ this.removeAttribute("inverted");
+ aMuxer.updateSearch();
+ }
+
+ function rightClickHandler(aEvent) {
+ if (aEvent.button == 2) {
+ // Toggle isn't triggered by a contextmenu event, so do it here.
+ this.pressed = !this.pressed;
+
+ let tagKey = this.getAttribute("value");
+ let state = aMuxer.getFilterValueForMutation(TagFacetingFilter.name);
+ state.tags[tagKey] = this.pressed ? false : null;
+ if (this.pressed) {
+ this.setAttribute("inverted", "true");
+ } else {
+ this.removeAttribute("inverted");
+ }
+ aMuxer.updateSearch();
+ aEvent.preventDefault();
+ }
+ }
+
+ // -- nuke existing exposed tags, but not the mode selector (which is first)
+ while (tagbar.children.length > 1) {
+ tagbar.lastElementChild.remove();
+ }
+
+ let addCount = 0;
+
+ // -- create an element for each tag
+ let tags = MailServices.tags.getAllTags();
+ let tagCount = tags.length;
+ for (let iTag = 0; iTag < tagCount; iTag++) {
+ let tag = tags[iTag];
+
+ if (tag.key in keywordMap) {
+ addCount++;
+
+ // Keep in mind that the XBL does not get built for dynamically created
+ // elements such as these until they get displayed, which definitely
+ // means not before we append it into the tree.
+ let button = aDocument.createElement("button", { is: "toggle-button" });
+
+ button.setAttribute("id", "qfb-tag-" + tag.key);
+ button.addEventListener("click", clickHandler);
+ button.addEventListener("contextmenu", rightClickHandler);
+ if (keywordMap[tag.key] !== null) {
+ button.pressed = true;
+ if (!keywordMap[tag.key]) {
+ button.setAttribute("inverted", "true");
+ }
+ }
+ button.textContent = tag.tag;
+ button.setAttribute("value", tag.key);
+ let color = tag.color;
+ let contrast = lazy.TagUtils.isColorContrastEnough(color)
+ ? "black"
+ : "white";
+ // everybody always gets to be an qfb-tag-button.
+ button.setAttribute("class", "button qfb-tag-button");
+ if (color) {
+ button.setAttribute(
+ "style",
+ `--tag-color: ${color}; --tag-contrast-color: ${contrast};`
+ );
+ }
+ tagbar.appendChild(button);
+ }
+ }
+ tagbar.hidden = !addCount;
+ },
+};
+QuickFilterManager.defineFilter(TagFacetingFilter);
+
+/**
+ * true: must have attachment, false: must not have attachment.
+ */
+QuickFilterManager.defineFilter({
+ name: "attachment",
+ domId: "qfb-attachment",
+ menuItemID: "quickFilterButtonsContextAttachmentToggle",
+ appendTerms(aTermCreator, aTerms, aFilterValue) {
+ let term, value;
+ term = aTermCreator.createTerm();
+ term.attrib = Ci.nsMsgSearchAttrib.MsgStatus;
+ value = term.value;
+ value.attrib = term.attrib;
+ value.status = Ci.nsMsgMessageFlags.Attachment;
+ term.value = value;
+ term.op = aFilterValue ? Ci.nsMsgSearchOp.Is : Ci.nsMsgSearchOp.Isnt;
+ term.booleanAnd = true;
+ aTerms.push(term);
+ },
+});
+
+/**
+ * The traditional quick-search text filter now with added gloda upsell! We
+ * are mildly extensible in case someone wants to add more specific text filter
+ * criteria to toggle, but otherwise are intended to be taken out of the
+ * picture entirely by extensions implementing more featureful text searches.
+ *
+ * Our state looks like {text: "", states: {a: true, b: false}} where a and b
+ * are text filters.
+ */
+var MessageTextFilter = {
+ name: "text",
+ domId: "qfb-qs-textbox",
+ /**
+ * Parse the string into terms/phrases by finding matching double-quotes. If
+ * we find a quote that doesn't have a friend, we assume the user was going
+ * to put a quote at the end of the string. (This is important because we
+ * update using a timer and this results in stable behavior.)
+ *
+ * This code is cloned from gloda's GlodaMsgSearcher.jsm and known good (enough :).
+ * I did change the friendless quote situation, though.
+ *
+ * @param aSearchString The phrase to parse up.
+ * @returns A list of terms.
+ */
+ _parseSearchString(aSearchString) {
+ aSearchString = aSearchString.trim();
+ let terms = [];
+
+ /*
+ * Add the term as long as the trim on the way in didn't obliterate it.
+ *
+ * In the future this might have other helper logic; it did once before.
+ */
+ function addTerm(aTerm) {
+ if (aTerm) {
+ terms.push(aTerm);
+ }
+ }
+
+ /**
+ * Look for spaces around | (OR operator) and remove them.
+ */
+ aSearchString = aSearchString.replace(/\s*\|\s*/g, "|");
+ while (aSearchString) {
+ if (aSearchString.startsWith('"')) {
+ let endIndex = aSearchString.indexOf('"', 1);
+ // treat a quote without a friend as making a phrase containing the
+ // rest of the string...
+ if (endIndex == -1) {
+ endIndex = aSearchString.length;
+ }
+
+ addTerm(aSearchString.substring(1, endIndex).trim());
+ aSearchString = aSearchString.substring(endIndex + 1);
+ continue;
+ }
+
+ let searchTerms = aSearchString.split(" ");
+ searchTerms.forEach(searchTerm => addTerm(searchTerm));
+ break;
+ }
+
+ return terms;
+ },
+
+ /**
+ * For each search phrase, build a group that contains all our active text
+ * filters OR'ed together. So if the user queries for 'foo bar' with
+ * sender and recipient enabled, we build:
+ * ("foo" sender OR "foo" recipient) AND ("bar" sender OR "bar" recipient)
+ */
+ appendTerms(aTermCreator, aTerms, aFilterValue) {
+ let term, value;
+
+ if (aFilterValue.text) {
+ let phrases = this._parseSearchString(aFilterValue.text);
+ for (let groupedPhrases of phrases) {
+ let firstClause = true;
+ term = null;
+ let splitPhrases = groupedPhrases.split("|");
+ for (let phrase of splitPhrases) {
+ for (let [tfName, tfValue] of Object.entries(aFilterValue.states)) {
+ if (!tfValue) {
+ continue;
+ }
+ let tfDef = this.textFilterDefs[tfName];
+
+ term = aTermCreator.createTerm();
+ term.attrib = tfDef.attrib;
+ value = term.value;
+ value.attrib = tfDef.attrib;
+ value.str = phrase;
+ term.value = value;
+ term.op = Ci.nsMsgSearchOp.Contains;
+ // AND for the group, but OR inside the group
+ term.booleanAnd = firstClause;
+ term.beginsGrouping = firstClause;
+ aTerms.push(term);
+ firstClause = false;
+ }
+ }
+ if (term) {
+ term.endsGrouping = true;
+ }
+ }
+ }
+ },
+ getDefaults() {
+ let states = {};
+ for (let name in this._defaultStates) {
+ states[name] = this._defaultStates[name];
+ }
+ return {
+ text: null,
+ states,
+ };
+ },
+ propagateState(aOld, aSticky) {
+ return {
+ text: aSticky ? aOld.text : null,
+ states: shallowObjCopy(aOld.states),
+ };
+ },
+ clearState(aState) {
+ let hadState = Boolean(aState.text);
+ aState.text = null;
+ return [aState, hadState];
+ },
+
+ /**
+ * We need to create and bind our expando-bar toggle buttons. We also need to
+ * add a special down keypress handler that escapes the textbox into the
+ * thread pane.
+ */
+ domBindExtra(aDocument, aMuxer, aNode) {
+ // -- Keypresses for focus transferral and upsell
+ aNode.addEventListener("keypress", function (aEvent) {
+ // - Down key into the thread pane. Calls `preventDefault` to stop the
+ // event from causing scrolling, but that prevents the tree from
+ // selecting a message if necessary, so we must do it here.
+ if (aEvent.keyCode == aEvent.DOM_VK_DOWN) {
+ let threadTree = aDocument.getElementById("threadTree");
+ threadTree.table.body.focus();
+ if (threadTree.selectedIndex == -1) {
+ threadTree.selectedIndex = 0;
+ }
+ aEvent.preventDefault();
+ }
+ });
+
+ // -- Blurring kills upsell.
+ aNode.addEventListener(
+ "blur",
+ function (aEvent) {
+ let panel = aDocument.getElementById("qfb-text-search-upsell");
+ if (
+ (Services.focus.activeWindow != aDocument.defaultView ||
+ aDocument.commandDispatcher.focusedElement != aNode.inputField) &&
+ panel.state == "open"
+ ) {
+ panel.hidePopup();
+ }
+ },
+ true
+ );
+
+ // -- Expando Buttons!
+ function commandHandler(aEvent) {
+ let state = aMuxer.getFilterValueForMutation(MessageTextFilter.name);
+ let filterDef = MessageTextFilter.textFilterDefsByDomId[this.id];
+ state.states[filterDef.name] = this.pressed;
+ aMuxer.updateSearch();
+ }
+
+ for (let name in this.textFilterDefs) {
+ let textFilter = this.textFilterDefs[name];
+ aDocument
+ .getElementById(textFilter.domId)
+ .addEventListener("click", commandHandler);
+ }
+ },
+
+ onCommand(aState, aNode, aEvent, aDocument) {
+ let text = aNode.value.length ? aNode.value : null;
+ if (text == aState.text) {
+ let upsell = aDocument.getElementById("qfb-text-search-upsell");
+ if (upsell.state == "open") {
+ upsell.hidePopup();
+ let tabmail =
+ aDocument.ownerGlobal.top.document.getElementById("tabmail");
+ tabmail.openTab("glodaFacet", {
+ searcher: new lazy.GlodaMsgSearcher(null, aState.text),
+ });
+ }
+ return [aState, false];
+ }
+
+ aState.text = text;
+ aDocument.getElementById("quick-filter-bar-filter-text-bar").hidden =
+ text == null;
+ return [aState, true];
+ },
+
+ reflectInDOM(aNode, aFilterValue, aDocument, aMuxer, aFromPFP) {
+ let panel = aDocument.getElementById("qfb-text-search-upsell");
+
+ if (aFromPFP == "nosale") {
+ if (panel.state != "closed") {
+ panel.hidePopup();
+ }
+ return;
+ }
+
+ if (aFromPFP == "upsell") {
+ let line2 = aDocument.getElementById("qfb-upsell-line-two");
+ aDocument.l10n.setAttributes(
+ line2,
+ "quick-filter-bar-gloda-upsell-line2",
+ { text: aFilterValue.text }
+ );
+
+ if (panel.state == "closed" && aDocument.activeElement == aNode) {
+ aDocument.ownerGlobal.setTimeout(() => {
+ panel.openPopup(
+ aDocument.getElementById("quick-filter-bar"),
+ "after_end",
+ -7,
+ 7,
+ false,
+ true
+ );
+ });
+ }
+ return;
+ }
+
+ // Make sure we have no visible upsell on state change while our textbox
+ // retains focus.
+ if (panel.state != "closed") {
+ panel.hidePopup();
+ }
+
+ // Update the text if it has changed (linux does weird things with empty
+ // text if we're transitioning emptytext to emptytext).
+ let desiredValue = aFilterValue.text || "";
+ if (aNode.value != desiredValue && aNode != aMuxer.activeElement) {
+ aNode.value = desiredValue;
+ }
+
+ // Update our expanded filters buttons.
+ let states = aFilterValue.states;
+ for (let name in this.textFilterDefs) {
+ let textFilter = this.textFilterDefs[name];
+ aDocument.getElementById(textFilter.domId).pressed =
+ states[textFilter.name];
+ }
+
+ // Toggle the expanded filters visibility.
+ aDocument.getElementById("quick-filter-bar-filter-text-bar").hidden =
+ aFilterValue.text == null;
+ },
+
+ /**
+ * In order to do our upsell we need to know when we are not getting any
+ * results.
+ */
+ postFilterProcess(aState, aViewWrapper, aFiltering) {
+ // If we're not filtering, not filtering on text, there are results, or
+ // gloda is not enabled so upselling makes no sense, then bail.
+ // (Currently we always return "nosale" to make sure our panel is closed;
+ // this might be overkill but unless it becomes a performance problem, it
+ // keeps us safe from weird stuff.)
+ if (
+ !aFiltering ||
+ !aState.text ||
+ aViewWrapper.dbView.numMsgsInView ||
+ !lazy.GlodaIndexer.enabled
+ ) {
+ return [aState, "nosale", false];
+ }
+
+ // since we're filtering, filtering on text, and there are no results, tell
+ // the upsell code to get bizzay
+ return [aState, "upsell", false];
+ },
+
+ /** maps text filter names to whether they are enabled by default (bool) */
+ _defaultStates: {},
+ /** maps text filter name to text filter def */
+ textFilterDefs: {},
+ /** maps dom id to text filter def */
+ textFilterDefsByDomId: {},
+ defineTextFilter(aTextDef) {
+ this.textFilterDefs[aTextDef.name] = aTextDef;
+ this.textFilterDefsByDomId[aTextDef.domId] = aTextDef;
+ if (aTextDef.defaultState) {
+ this._defaultStates[aTextDef.name] = true;
+ }
+ },
+};
+// Note that we definitely want this filter defined AFTER the cheap message
+// status filters, so don't reorder this invocation willy nilly.
+QuickFilterManager.defineFilter(MessageTextFilter);
+QuickFilterManager.textBoxDomId = "qfb-qs-textbox";
+
+MessageTextFilter.defineTextFilter({
+ name: "sender",
+ domId: "qfb-qs-sender",
+ attrib: Ci.nsMsgSearchAttrib.Sender,
+ defaultState: true,
+});
+MessageTextFilter.defineTextFilter({
+ name: "recipients",
+ domId: "qfb-qs-recipients",
+ attrib: Ci.nsMsgSearchAttrib.ToOrCC,
+ defaultState: true,
+});
+MessageTextFilter.defineTextFilter({
+ name: "subject",
+ domId: "qfb-qs-subject",
+ attrib: Ci.nsMsgSearchAttrib.Subject,
+ defaultState: true,
+});
+MessageTextFilter.defineTextFilter({
+ name: "body",
+ domId: "qfb-qs-body",
+ attrib: Ci.nsMsgSearchAttrib.Body,
+ defaultState: false,
+});
+
+/**
+ * The results label says whether there were any matches and, if so, how many.
+ */
+QuickFilterManager.defineFilter({
+ name: "results",
+ domId: "qfb-results-label",
+ appendTerms(aTermCreator, aTerms, aFilterValue) {},
+
+ /**
+ * Our state is meaningless; we implement this to avoid clearState ever
+ * thinking we were a facet.
+ */
+ clearState(aState) {
+ return [null, false];
+ },
+
+ /**
+ * We never have any state to propagate!
+ */
+ propagateState(aOld, aSticky) {
+ return null;
+ },
+
+ reflectInDOM(aNode, aFilterValue, aDocument) {
+ if (aFilterValue == null) {
+ aNode.removeAttribute("data-l10n-id");
+ aNode.removeAttribute("data-l10n-attrs");
+ aNode.textContent = "";
+ aNode.style.visibility = "hidden";
+ } else if (aFilterValue == 0) {
+ aDocument.l10n.setAttributes(aNode, "quick-filter-bar-no-results");
+ aNode.style.visibility = "visible";
+ } else {
+ aDocument.l10n.setAttributes(aNode, "quick-filter-bar-results", {
+ count: aFilterValue,
+ });
+ aNode.style.visibility = "visible";
+ }
+ },
+ /**
+ * We slightly abuse the filtering hook to figure out how many messages there
+ * are and whether a filter is active. What makes this reasonable is that
+ * a more complicated widget that visualized the results as a timeline would
+ * definitely want to be hooked up like this. (Although they would want
+ * to implement propagateState since the state they store would be pretty
+ * expensive.)
+ */
+ postFilterProcess(aState, aViewWrapper, aFiltering) {
+ return [aFiltering ? aViewWrapper.dbView.numMsgsInView : null, true, false];
+ },
+});