diff options
Diffstat (limited to 'comm/mail/modules/QuickFilterManager.jsm')
-rw-r--r-- | comm/mail/modules/QuickFilterManager.jsm | 1369 |
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]; + }, +}); |