1 /*
  2  * ***** BEGIN LICENSE BLOCK *****
  3  * Zimbra Collaboration Suite Web Client
  4  * Copyright (C) 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016 Synacor, Inc.
  5  *
  6  * The contents of this file are subject to the Common Public Attribution License Version 1.0 (the "License");
  7  * you may not use this file except in compliance with the License.
  8  * You may obtain a copy of the License at: https://www.zimbra.com/license
  9  * The License is based on the Mozilla Public License Version 1.1 but Sections 14 and 15
 10  * have been added to cover use of software over a computer network and provide for limited attribution
 11  * for the Original Developer. In addition, Exhibit A has been modified to be consistent with Exhibit B.
 12  *
 13  * Software distributed under the License is distributed on an "AS IS" basis,
 14  * WITHOUT WARRANTY OF ANY KIND, either express or implied.
 15  * See the License for the specific language governing rights and limitations under the License.
 16  * The Original Code is Zimbra Open Source Web Client.
 17  * The Initial Developer of the Original Code is Zimbra, Inc.  All rights to the Original Code were
 18  * transferred by Zimbra, Inc. to Synacor, Inc. on September 14, 2015.
 19  *
 20  * All portions of the code are Copyright (C) 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016 Synacor, Inc. All Rights Reserved.
 21  * ***** END LICENSE BLOCK *****
 22  */
 23 
 24 /**
 25  * @overview
 26  * This file defines a list controller class.
 27  *
 28  */
 29 
 30 /**
 31  * This class is a base class for any controller that manages a list of items such as mail messages
 32  * or contacts. It can handle alternative views of the same list.
 33  *
 34  * @author Conrad Damon
 35  *
 36  * @param {DwtControl}					container					the containing shell
 37  * @param {ZmApp}						app							the containing application
 38  * @param {constant}					type						type of controller
 39  * @param {string}						sessionId					the session id
 40  * @param {ZmSearchResultsController}	searchResultsController		containing controller
 41  * 
 42  * @extends		ZmBaseController
 43  */
 44 ZmListController = function(container, app, type, sessionId, searchResultsController) {
 45 
 46 	if (arguments.length == 0) { return; }
 47 	ZmBaseController.apply(this, arguments);
 48 
 49 	// hashes keyed by view type
 50 	this._navToolBar = {};			// ZmNavToolBar
 51 	this._listView = this._view;	// ZmListView (back-compatibility for bug 60073)
 52 
 53 	this._list = null;				// ZmList
 54 	this._activeSearch = null;
 55 	this._newButton = null;
 56 	this._actionMenu = null;		// ZmActionMenu
 57 	this._actionEv = null;
 58 	
 59 	if (this.supportsDnD()) {
 60 		this._dropTgt = new DwtDropTarget("ZmTag");
 61 		this._dropTgt.markAsMultiple();
 62 		this._dropTgt.addDropListener(this._dropListener.bind(this));
 63 	}
 64 
 65 	this._menuPopdownListener = this._menuPopdownActionListener.bind(this);
 66 	
 67 	this._itemCountText = {};
 68 	this._continuation = {count:0, totalItems:0};
 69 };
 70 
 71 ZmListController.prototype = new ZmBaseController;
 72 ZmListController.prototype.constructor = ZmListController;
 73 
 74 ZmListController.prototype.isZmListController = true;
 75 ZmListController.prototype.toString = function() { return "ZmListController"; };
 76 
 77 // When performing a search action (bug 10317) on all items (including those not loaded),
 78 // number of items to load on each search to work through all results. Should be a multiple
 79 // of ZmList.CHUNK_SIZE. Make sure to test if you change these.
 80 ZmListController.CONTINUATION_SEARCH_ITEMS = 500;
 81 
 82 // states of the progress dialog
 83 ZmListController.PROGRESS_DIALOG_INIT	= "INIT";
 84 ZmListController.PROGRESS_DIALOG_UPDATE	= "UPDATE";
 85 ZmListController.PROGRESS_DIALOG_CLOSE	= "CLOSE";
 86 
 87 
 88 /**
 89  * Performs some setup for displaying the given search results in a list view. Subclasses will need
 90  * to do the actual display work, typically by calling the list view's {@link #set} method.
 91  *
 92  * @param {ZmSearchResult}	searchResults		the search results
 93  */
 94 ZmListController.prototype.show	=
 95 function(searchResults) {
 96 	
 97 	this._activeSearch = searchResults;
 98 	// save current search for use by replenishment
 99 	if (searchResults) {
100 		this._currentSearch = searchResults.search;
101 		this._activeSearch.viewId = this._currentSearch.viewId = this._currentViewId;
102 	}
103 	this.currentPage = 1;
104 	this.maxPage = 1;
105 };
106 
107 /**
108  * Returns the current list view.
109  * 
110  * @return {ZmListView}	the list view
111  */
112 ZmListController.prototype.getListView =
113 function() {
114 	return this._view[this._currentViewId];
115 };
116 
117 /**
118  * Gets the current search results.
119  * 
120  * @return	{ZmSearchResults}	current search results
121  */
122 ZmListController.prototype.getCurrentSearchResults =
123 function() {
124 	return this._activeSearch;
125 };
126 
127 /**
128  * Gets the search string.
129  * 
130  * @return	{String}	the search string
131  */
132 ZmListController.prototype.getSearchString =
133 function() {
134 	return this._currentSearch ? this._currentSearch.query : "";
135 };
136 
137 
138 ZmListController.prototype.setSearchString =
139 function(query) {
140 	this._currentSearch.query = query;
141 };
142 
143 /**
144  * Gets the search string hint.
145  * 
146  * @return	{String}	the search string hint
147  */
148 ZmListController.prototype.getSearchStringHint =
149 function() {
150 	return this._currentSearch ? this._currentSearch.queryHint : "";
151 };
152 
153 ZmListController.prototype.getSelection =
154 function(view) {
155     view = view || this.getListView();
156     return view ? view.getSelection() : [];
157 };
158 
159 ZmListController.prototype.getSelectionCount =
160 function(view) {
161     view = view || this.getListView();
162     return view ? view.getSelectionCount() : 0;
163 };
164 
165 /**
166  * Gets the list.
167  * 
168  * @return	{ZmList}		the list
169  */
170 ZmListController.prototype.getList =
171 function() {
172 	return this._list;
173 };
174 
175 /**
176  * Sets the list.
177  * 
178  * @param	{ZmList}	newList		the new list
179  */
180 ZmListController.prototype.setList =
181 function(newList) {
182 	if (newList != this._list && newList.isZmList) {
183 		if (this._list) {
184 			this._list.clear();	// also removes change listeners
185 		}
186 		this._list = newList;
187 		this._list.controller = this;
188 	}
189 };
190 
191 /**
192  * Sets the "has more" state.
193  * 
194  * @param	{Boolean}	hasMore		<code>true</code> if has more
195  */
196 ZmListController.prototype.setHasMore =
197 function(hasMore) {
198 	// Note: This is a bit of a HACK that is an attempt to overcome an
199 	// offline issue. The problem is during initial sync when more
200 	// messages come in: the forward navigation arrow doesn't get enabled.
201 	
202 	if (hasMore && this._list) {
203 		// bug: 30546
204 		this._list.setHasMore(hasMore);
205 		this._resetNavToolBarButtons();
206 	}
207 };
208 
209 /**
210  * Returns a list of the selected items.
211  */
212 ZmListController.prototype.getItems =
213 function() {
214 	return this.getSelection();
215 };
216 
217 /**
218  * Returns the number of selected items.
219  */
220 ZmListController.prototype.getItemCount =
221 function() {
222 	return this.getSelectionCount();
223 };
224 
225 /**
226  * Handles the key action.
227  * 
228  * @param	{constant}	actionCode		the action code
229  * @return	{Boolean}	<code>true</code> if the action is handled
230  */
231 ZmListController.prototype.handleKeyAction =
232 function(actionCode, ev) {
233 
234 	DBG.println(AjxDebug.DBG3, "ZmListController.handleKeyAction");
235 	var listView = this._view[this._currentViewId];
236 	var result = false;
237     var activeEl = document.activeElement;
238 
239 	switch (actionCode) {
240 
241 		case DwtKeyMap.DBLCLICK:
242             if (activeEl && activeEl.nodeName && activeEl.nodeName.toLowerCase() === 'a') {
243                 return false;
244             }
245 			return listView.handleKeyAction(actionCode);
246 
247 		case ZmKeyMap.SHIFT_DEL:
248 		case ZmKeyMap.DEL:
249 			var tb = this.getCurrentToolbar();
250 			var button = tb && (tb.getButton(ZmOperation.DELETE) || tb.getButton(ZmOperation.DELETE_MENU));
251 			if (button && button.getEnabled()) {
252 				this._doDelete(this.getSelection(), (actionCode == ZmKeyMap.SHIFT_DEL));
253 				result = true;
254 			}
255 			break;
256 
257 		case ZmKeyMap.NEXT_PAGE:
258 			var ntb = this._navToolBar[this._currentViewId];
259 			var button = ntb ? ntb.getButton(ZmOperation.PAGE_FORWARD) : null;
260 			if (button && button.getEnabled()) {
261 				this._paginate(this._currentViewId, true);
262 				result = true;
263 			}
264 			break;
265 
266 		case ZmKeyMap.PREV_PAGE:
267 			var ntb = this._navToolBar[this._currentViewId];
268 			var button = ntb ? ntb.getButton(ZmOperation.PAGE_BACK) : null;
269 			if (button && button.getEnabled()) {
270 				this._paginate(this._currentViewId, false);
271 				result = true;
272 			}
273 			break;
274 
275 		// Esc pops search results tab
276 		case ZmKeyMap.CANCEL:
277 			var ctlr = this.isSearchResults && this.searchResultsController;
278 			if (ctlr) {
279 				ctlr._closeListener();
280 			}
281 			break;
282 
283 		default:
284 			return ZmBaseController.prototype.handleKeyAction.apply(this, arguments);
285 	}
286 	return result;
287 };
288 
289 // Returns a list of desired action menu operations
290 ZmListController.prototype._getActionMenuOps = function() {};
291 
292 /**
293  * @private
294  */
295 ZmListController.prototype._standardActionMenuOps =
296 function() {
297 	return [ZmOperation.TAG_MENU, ZmOperation.MOVE, ZmOperation.PRINT];
298 };
299 
300 /**
301  * @private
302  */
303 ZmListController.prototype._participantOps =
304 function() {
305 	var ops = [ZmOperation.SEARCH_MENU];
306 
307 	if (appCtxt.get(ZmSetting.MAIL_ENABLED)) {
308 		ops.push(ZmOperation.NEW_MESSAGE);
309 	}
310 
311 	if (appCtxt.get(ZmSetting.CONTACTS_ENABLED)) {
312 		ops.push(ZmOperation.CONTACT);
313 	}
314 
315 	return ops;
316 };
317 
318 /**
319  * Initializes action menu: menu items and listeners
320  * 
321  * @private
322  */
323 ZmListController.prototype._initializeActionMenu =
324 function() {
325 
326 	if (this._actionMenu) { return; }
327 
328 	var menuItems = this._getActionMenuOps();
329 	if (!menuItems) { return; }
330 
331 	var menuParams = {parent:this._shell,
332 		menuItems:	menuItems,
333 		context:	this._getMenuContext(),
334 		controller:	this
335 	};
336 	this._actionMenu = new ZmActionMenu(menuParams);
337 	this._addMenuListeners(this._actionMenu);
338 	if (appCtxt.get(ZmSetting.TAGGING_ENABLED)) {
339 		this._setupTagMenu(this._actionMenu);
340 	}
341 };
342 
343 /**
344  * Sets up tab groups (focus ring).
345  *
346  * @private
347  */
348 ZmListController.prototype._initializeTabGroup =
349 function(view) {
350 	if (this._tabGroups[view]) { return; }
351 
352 	ZmBaseController.prototype._initializeTabGroup.apply(this, arguments);
353 
354 	var navToolBar = this._navToolBar[view];
355 	if (navToolBar) {
356 		this._tabGroups[view].addMember(navToolBar.getTabGroupMember());
357 	}
358 };
359 
360 /**
361  * Gets the tab group.
362  * 
363  * @return	{Object}	the tab group
364  */
365 ZmListController.prototype.getTabGroup =
366 function() {
367 	return this._tabGroups[this._currentViewId];
368 };
369 
370 /**
371  * @private
372  */
373 ZmListController.prototype._addMenuListeners =
374 function(menu) {
375 
376 	var menuItems = menu.opList;
377 	for (var i = 0; i < menuItems.length; i++) {
378 		var menuItem = menuItems[i];
379 		if (this._listeners[menuItem]) {
380 			menu.addSelectionListener(menuItem, this._listeners[menuItem], 0);
381 		}
382 	}
383 	menu.addPopdownListener(this._menuPopdownListener);
384 };
385 
386 ZmListController.prototype._menuPopdownActionListener =
387 function(ev) {
388 
389 	var view = this.getListView();
390 	if (!this._pendingActionData) {
391 		if (view && view.handleActionPopdown) {
392 			view.handleActionPopdown(ev);
393 		}
394 	}
395 	// Reset back to item count unless there is multiple selection
396 	var selCount = view ? view.getSelectionCount() : -1;
397 	if (selCount <= 1) {
398 		this._setItemCountText();
399 	}
400 };
401 
402 
403 
404 // List listeners
405 
406 /**
407  * List selection event - handle flagging if a flag icon was clicked, otherwise
408  * reset the toolbar based on how many items are selected.
409  * 
410  * @private
411  */
412 ZmListController.prototype._listSelectionListener =
413 function(ev) {
414 
415 	if (ev.field == ZmItem.F_FLAG) {
416 		this._doFlag([ev.item]);
417 		return true;
418 	} 
419 	else {
420 		var lv = this._listView[this._currentViewId];
421 		if (lv) {
422 			if (appCtxt.get(ZmSetting.SHOW_SELECTION_CHECKBOX) && !ev.ctrlKey) {
423 				if (lv.setSelectionHdrCbox) {
424 					lv.setSelectionHdrCbox(false);
425 				}
426 			}
427 			this._resetOperations(this.getCurrentToolbar(), lv.getSelectionCount());
428 			if (ev.shiftKey) {
429 				this._setItemSelectionCountText();
430 			}
431 			else {
432 				this._setItemCountText();
433 			}
434 		}
435 	}
436 	return false;
437 };
438 
439 /**
440  * List action event - set the dynamic tag menu, and enable operations in the
441  * action menu based on the number of selected items. Note that the menu is not
442  * actually popped up here; that's left up to the subclass, which should
443  * override this function.
444  * 
445  * @private
446  */
447 ZmListController.prototype._listActionListener =
448 function(ev) {
449 
450 	this._actionEv = ev;
451 	var actionMenu = this.getActionMenu();
452 	if (appCtxt.get(ZmSetting.TAGGING_ENABLED)) {
453 		this._setTagMenu(actionMenu);
454 	}
455 
456     if (appCtxt.get(ZmSetting.SEARCH_ENABLED)) {
457         this._setSearchMenu(actionMenu);
458     }
459 	this._resetOperations(actionMenu, this.getSelectionCount());
460 	this._setItemSelectionCountText();
461 };
462 
463 
464 // Navbar listeners
465 
466 /**
467  * @private
468  */
469 ZmListController.prototype._navBarListener =
470 function(ev) {
471 
472 	// skip listener for non-current views
473 	if (!this.isCurrent()) { return; }
474 
475 	var op = ev.item.getData(ZmOperation.KEY_ID);
476 
477 	if (op == ZmOperation.PAGE_BACK || op == ZmOperation.PAGE_FORWARD) {
478 		this._paginate(this._currentViewId, (op == ZmOperation.PAGE_FORWARD));
479 	}
480 };
481 
482 // Drag and drop listeners
483 
484 /**
485  * @private
486  */
487 ZmListController.prototype._dragListener =
488 function(ev) {
489 
490 	if (this.isSearchResults && ev.action == DwtDragEvent.DRAG_START) {
491 		this.searchResultsController.showOverview(true);
492 	}
493 	else if (ev.action == DwtDragEvent.SET_DATA) {
494 		ev.srcData = {data: ev.srcControl.getDnDSelection(), controller: this};
495 	}
496 	else if (this.isSearchResults && (ev.action == DwtDragEvent.DRAG_END || ev.action == DwtDragEvent.DRAG_CANCEL)) {
497 		this.searchResultsController.showOverview(false);
498 	}
499 };
500 
501 /**
502  * The list view as a whole is the drop target, since it's the lowest-level widget. Still, we
503  * need to find out which item got dropped onto, so we get that from the original UI event
504  * (a mouseup). The header is within the list view, but not an item, so it's not a valid drop
505  * target. One drawback of having the list view be the drop target is that we can't exercise
506  * fine-grained control on what's a valid drop target. If you enter via an item and then drag to
507  * the header, it will appear to be valid.
508  * 
509  * @protected
510  */
511 ZmListController.prototype._dropListener =
512 function(ev) {
513 
514 	var view = this._view[this._currentViewId];
515 	var div = view.getTargetItemDiv(ev.uiEvent);
516 	var item = view.getItemFromElement(div);
517 
518 	// only tags can be dropped on us
519 	var data = ev.srcData.data;
520 	if (ev.action == DwtDropEvent.DRAG_ENTER) {
521 		ev.doIt = (item && (item instanceof ZmItem) && !item.isReadOnly() && this._dropTgt.isValidTarget(data));
522         // Bug: 44488 - Don't allow dropping tag of one account to other account's item
523         if (appCtxt.multiAccounts) {
524            var listAcctId = item ? item.getAccount().id : null;
525            var tagAcctId = (data.account && data.account.id) || data[0].account.id;
526            if (listAcctId != tagAcctId) {
527                ev.doIt = false;
528            }
529         }
530 		DBG.println(AjxDebug.DBG3, "DRAG_ENTER: doIt = " + ev.doIt);
531 		if (ev.doIt) {
532 			view.dragSelect(div);
533 		}
534 	} else if (ev.action == DwtDropEvent.DRAG_DROP) {
535 		view.dragDeselect(div);
536 		var items = [item];
537 		var sel = this.getSelection();
538 		if (sel.length) {
539 			var vec = AjxVector.fromArray(sel);
540 			if (vec.contains(item)) {
541 				items = sel;
542 			}
543 		}
544 		this._doTag(items, data, true);
545 	} else if (ev.action == DwtDropEvent.DRAG_LEAVE) {
546 		view.dragDeselect(div);
547 	} else if (ev.action == DwtDropEvent.DRAG_OP_CHANGED) {
548 		// nothing
549 	}
550 };
551 
552 /**
553  * @private
554  */
555 
556 /**
557  * returns true if the search folder is drafts
558  */
559 ZmListController.prototype.isDraftsFolder =
560 function() {
561 	var folder = this._getSearchFolder();
562 	if (!folder) {
563 		return false;
564 	}
565 	return folder.nId ==  ZmFolder.ID_DRAFTS;
566 };
567 
568 /**
569  * returns true if the search folder is drafts
570  */
571 ZmListController.prototype.isOutboxFolder =
572 function() {
573     var folder = this._getSearchFolder();
574     if (!folder) {
575         return false;
576     }
577     return folder.nId == ZmFolder.ID_OUTBOX;
578 };
579 
580 /**
581  * returns true if the search folder is sync failures
582  */
583 ZmListController.prototype.isSyncFailuresFolder =
584 function() {
585 	var folder = this._getSearchFolder();
586 	if (!folder) {
587 		return false;
588 	}
589 	return folder.nId ==  ZmFolder.ID_SYNC_FAILURES;
590 };
591 
592 
593 // Actions on items are performed through their containing list
594 ZmListController.prototype._getList =
595 function(items) {
596 
597 	var list = ZmBaseController.prototype._getList.apply(this, arguments);
598 	if (!list) {
599 		list = this._list;
600 	}
601 
602 	return list;
603 };
604 
605 // if items were removed, see if we need to fetch more
606 ZmListController.prototype._getAllDoneCallback =
607 function() {
608 	return this._checkItemCount.bind(this);
609 };
610 
611 /**
612  * Manages the progress dialog that appears when an action is performed on a large number of items.
613  * The arguments include a state and any arguments relative to that state. The state is one of:
614  * 
615  * 			ZmListController.PROGRESS_DIALOG_INIT
616  *			ZmListController.PROGRESS_DIALOG_UPDATE
617  *			ZmListController.PROGRESS_DIALOG_CLOSE
618  *  
619  * @param {hash}		params		a hash of params:
620  * @param {constant}	state		state of the dialog
621  * @param {AjxCallback}	callback	cancel callback (INIT)
622  * @param {string}		summary		summary text (UPDATE)
623  */
624 ZmListController.handleProgress =
625 function(params) {
626 
627 	var dialog = appCtxt.getCancelMsgDialog();
628 	if (params.state == ZmListController.PROGRESS_DIALOG_INIT) {
629 		dialog.reset();
630 		dialog.registerCallback(DwtDialog.CANCEL_BUTTON, params.callback);
631 		ZmListController.progressDialogReady = true;
632 	}
633 	else if (params.state == ZmListController.PROGRESS_DIALOG_UPDATE && ZmListController.progressDialogReady) {
634 		dialog.setMessage(params.summary, DwtMessageDialog.INFO_STYLE, AjxMessageFormat.format(ZmMsg.inProgress));
635 		if (!dialog.isPoppedUp()) {
636 			dialog.popup();
637 		}
638 	}
639 	else if (params.state == ZmListController.PROGRESS_DIALOG_CLOSE) {
640 		dialog.unregisterCallback(DwtDialog.CANCEL_BUTTON);
641 		dialog.popdown();
642 		ZmListController.progressDialogReady = false;
643 	}
644 };
645 
646 
647 // Pagination
648 
649 /**
650  * @private
651  */
652 ZmListController.prototype._cacheList =
653 function(search, offset) {
654 
655 	if (this._list) {
656 		var newList = search.getResults().getVector();
657 		offset = offset ? offset : parseInt(search.getAttribute("offset"));
658 		this._list.cache(offset, newList);
659 	} else {
660 		this._list = search.getResults(type);
661 	}
662 };
663 
664 /**
665  * @private
666  */
667 ZmListController.prototype._search =
668 function(view, offset, limit, callback, isCurrent, lastId, lastSortVal) {
669 	var originalSearch = this._activeSearch && this._activeSearch.search;
670 	var params = {
671 		query:			this.getSearchString(),
672 		queryHint:		this.getSearchStringHint(),
673 		types:			originalSearch && originalSearch.types || [], // use types from original search
674 		userInitiated:	originalSearch && originalSearch.userInitiated,
675 		sortBy:			appCtxt.get(ZmSetting.SORTING_PREF, view),
676 		offset:			offset,
677 		limit:			limit,
678 		lastId:			lastId,
679 		lastSortVal:	lastSortVal
680 	};
681 	// add any additional params...
682 	this._getMoreSearchParams(params);
683 
684 	var search = new ZmSearch(params);
685 	if (isCurrent) {
686 		this._currentSearch = search;
687 	}
688 
689 	appCtxt.getSearchController().redoSearch(search, true, null, callback);
690 };
691 
692 /**
693  * Gets next or previous page of items. The set of items may come from the
694  * cached list, or from the server (using the current search as a base).
695  * <p>
696  * The loadIndex is the index'd item w/in the list that needs to be loaded -
697  * initiated only when user is in CV and pages a conversation that has not
698  * been loaded yet.</p>
699  * <p>
700  * Note that this method returns a value even though it may make an
701  * asynchronous SOAP request. That's possible as long as no caller
702  * depends on the results of that request. Currently, the only caller that
703  * looks at the return value acts on it only if no request was made.</p>
704  *
705  * @param {constant}	view		the current view
706  * @param {Boolean}	forward		if <code>true</code>, get next page rather than previous
707  * @param {int}		loadIndex	the index of item to show
708  * @param {int}	limit		the number of items to fetch
709  * 
710  * @private
711  */
712 ZmListController.prototype._paginate =
713 function(view, forward, loadIndex, limit) {
714 
715 	var needMore = false;
716 	var lv = this._view[view];
717 	if (!lv) { return; }
718 	var offset, max;
719 
720     limit = limit || lv.getLimit(offset);
721 
722 	if (lv._isPageless) {
723 		offset = this._list.size();
724 		needMore = true;
725 	} else {
726 		offset = lv.getNewOffset(forward);
727 		needMore = (offset + limit > this._list.size());
728 		this.currentPage = this.currentPage + (forward ? 1 : -1);
729 		this.maxPage = Math.max(this.maxPage, this.currentPage);
730 	}
731 
732 	// see if we're out of items and the server has more
733 	if (needMore && this._list.hasMore()) {
734 		lv.offset = offset; // cache new offset
735 		if (lv._isPageless) {
736 			max = limit;
737 		} else {
738 			// figure out how many items we need to fetch
739 			var delta = (offset + limit) - this._list.size();
740 			max = delta < limit && delta > 0 ? delta : limit;
741 			if (max < limit) {
742 				offset = ((offset + limit) - max) + 1;
743 			}
744 		}
745 
746 		// handle race condition - user has paged quickly and we don't want
747 		// to do second fetch while one is pending
748 		if (this._searchPending) { return false;	}
749 
750 		// figure out if this requires cursor-based paging
751 		var list = lv.getList();
752 		var lastItem = list && list.getLast();
753 		var lastSortVal = (lastItem && lastItem.id) ? lastItem.sf : null;
754 		var lastId = lastSortVal ? lastItem.id : null;
755 
756 		this._setItemCountText(ZmMsg.loading);
757 
758 		// get next page of items from server; note that callback may be overridden
759 		this._searchPending = true;
760 		var respCallback = this._handleResponsePaginate.bind(this, view, false, loadIndex, offset);
761 		this._search(view, offset, max, respCallback, true, lastId, lastSortVal);
762 		return false;
763 	} else if (!lv._isPageless) {
764 		lv.offset = offset; // cache new offset
765 		this._resetOperations(this._toolbar[view], 0);
766 		this._resetNavToolBarButtons(view);
767 		this._setViewContents(view);
768 		this._resetSelection();
769 		return true;
770 	}
771 };
772 
773 /**
774  * Updates the list and the view after a new page of items has been retrieved.
775  *
776  * @param {constant}	view				the current view
777  * @param {Boolean}	saveSelection			if <code>true</code>, maintain current selection
778  * @param {int}	loadIndex				the index of item to show
779  * @param {ZmCsfeResult}	result			the result of SOAP request
780  * @param {Boolean}	ignoreResetSelection	if <code>true</code>, do not reset selection
781  * 
782  * @private
783  */
784 ZmListController.prototype._handleResponsePaginate =
785 function(view, saveSelection, loadIndex, offset, result, ignoreResetSelection) {
786 
787 	var searchResult = result.getResponse();
788 
789 	// update "more" flag
790 	this._list.setHasMore(searchResult.getAttribute("more"));
791 
792 	this._cacheList(searchResult, offset);
793 
794 	var lv = this._view[this._currentViewId];
795 	var num = lv._isPageless ? this.getSelectionCount() : 0;
796 	this._resetOperations(this._toolbar[view], num);
797 
798 	// remember selected index if told to
799 	var selItem = saveSelection ? this.getSelection()[0] : null;
800 	var selectedIdx = selItem ? lv.getItemIndex(selItem) : -1;
801 
802 	var items = searchResult && searchResult.getResults().getArray();
803 	if (lv._isPageless && items && items.length) {
804 		lv._itemsToAdd = items;
805 	} else {
806 		lv._itemsToAdd = null;
807 	}
808 	var wasEmpty = (lv._isPageless && (lv.size() == 0));
809 
810 	this._setViewContents(view);
811 
812 	// add new items to selection if all results selected, in a way that doesn't call deselectAll()
813 	if (lv.allSelected) {
814 		for (var i = 0, len = items.length; i < len; i++) {
815 			lv.selectItem(items[i], true);
816 			lv.setSelectionCbox(items[i], false);
817 		}
818 		lv.setSelectionHdrCbox(true);
819 		DBG.println("scr", "pagination - selected more items: " + items.length);
820 		DBG.println("scr", "items selected: " + this.getSelectionCount());
821 	}
822 	this._resetNavToolBarButtons(view);
823 
824 	// bug fix #5134 - some views may not want to reset the current selection
825 	if (!ignoreResetSelection && !lv._isPageless) {
826 		this._resetSelection(selectedIdx);
827 	} else if (wasEmpty) {
828 		lv._setNextSelection();
829 	}
830 
831 	this._searchPending = false;
832 };
833 
834 /**
835  * @private
836  */
837 ZmListController.prototype._getMoreSearchParams =
838 function(params) {
839 	// overload me if more params are needed for SearchRequest
840 };
841 
842 /**
843  * @private
844  */
845 ZmListController.prototype._checkReplenish =
846 function(callback) {
847 
848 	var view = this.getListView();
849 	var list = view.getList();
850 	// don't bother if the view doesn't really have a list
851 	var replenishmentDone = false;
852 	if (list) {
853 		var replCount = view.getLimit() - view.size();
854 		if (replCount > view.getReplenishThreshold()) {
855 			this._replenishList(this._currentViewId, replCount, callback);
856 			replenishmentDone = true;
857 		}
858 	}
859 	if (callback && !replenishmentDone) {
860 		callback.run();
861 	}
862 };
863 
864 /**
865  * All items in the list view are gone - show "No Results".
866  * 
867  * @private
868  */
869 ZmListController.prototype._handleEmptyList =
870 function(listView) {
871 	if (this.currentPage > 1) {
872 		this._paginate(this._currentViewId, false, 0);
873 	} else {
874 		listView.removeAll(true);
875 		listView._setNoResultsHtml();
876 		this._resetNavToolBarButtons();
877 		listView._checkItemCount();
878 	}
879 };
880 
881 /**
882  * @private
883  */
884 ZmListController.prototype._replenishList =
885 function(view, replCount, callback) {
886 
887 	// determine if there are any more items to replenish with
888 	var idxStart = this._view[view].offset + this._view[view].size();
889 	var totalCount = this._list.size();
890 
891 	if (idxStart < totalCount) {
892 		// replenish from cache
893 		var idxEnd = (idxEnd > totalCount) ? totalCount : (idxStart + replCount);
894 		var list = this._list.getVector().getArray();
895 		var sublist = list.slice(idxStart, idxEnd);
896 		var subVector = AjxVector.fromArray(sublist);
897 		this._view[view].replenish(subVector);
898 		if (callback) {
899 			callback.run();
900 		}
901 	} else {
902 		// replenish from server request
903 		this._getMoreToReplenish(view, replCount, callback);
904 	}
905 };
906 
907 /**
908  * @private
909  */
910 ZmListController.prototype._resetSelection =
911 function(idx) {
912 	var list = this.getListView().getList();
913 	if (list) {
914 		var selIdx = idx >= 0 ? idx : 0;
915 		var first = list.get(selIdx);
916 		this._view[this._currentViewId].setSelection(first, false);
917 	}
918 };
919 
920 /**
921  * Requests replCount items from the server to replenish current listview.
922  *
923  * @param {constant}	view		the current view to replenish
924  * @param {int}	replCount 	the number of items to replenish
925  * @param {AjxCallback}	callback	the async callback
926  * 
927  * @private
928  */
929 ZmListController.prototype._getMoreToReplenish =
930 function(view, replCount, callback) {
931 
932 	if (this._list.hasMore()) {
933 		// use a cursor if we can
934 		var list = this._view[view].getList();
935 		var lastItem = list.getLast();
936 		var lastSortVal = (lastItem && lastItem.id) ? lastItem.sf : null;
937 		var lastId = lastSortVal ? lastItem.id : null;
938 		var respCallback = this._handleResponseGetMoreToReplenish.bind(this, view, callback);
939 		this._search(view, this._list.size(), replCount, respCallback, false, lastId, lastSortVal);
940 	} else {
941 		if (callback) {
942 			callback.run();
943 		}
944 	}
945 };
946 
947 /**
948  * @private
949  */
950 ZmListController.prototype._handleResponseGetMoreToReplenish =
951 function(view, callback, result) {
952 
953 	var searchResult = result.getResponse();
954 
955 	// set updated has more flag
956 	var more = searchResult.getAttribute("more");
957 	this._list.setHasMore(more);
958 
959 	// cache search results into internal list
960 	this._cacheList(searchResult);
961 
962 	// update view w/ replenished items
963 	var list = searchResult.getResults().getVector();
964 	this._view[view].replenish(list);
965 
966 	// reset forward pagination button only
967 	this._toolbar[view].enable(ZmOperation.PAGE_FORWARD, more);
968 
969 	if (callback) {
970 		callback.run(result);
971 	}
972 };
973 
974 ZmListController.prototype._initializeNavToolBar =
975 function(view) {
976 	var tb = new ZmNavToolBar({parent:this._toolbar[view], context:view});
977 	this._setNavToolBar(tb, view);
978 };
979 
980 ZmListController.prototype._setNavToolBar =
981 function(toolbar, view) {
982 	this._navToolBar[view] = toolbar;
983 	if (this._navToolBar[view]) {
984 		var navBarListener = this._navBarListener.bind(this);
985 		this._navToolBar[view].addSelectionListener(ZmOperation.PAGE_BACK, navBarListener);
986 		this._navToolBar[view].addSelectionListener(ZmOperation.PAGE_FORWARD, navBarListener);
987 	}
988 };
989 
990 /**
991  * @private
992  */
993 ZmListController.prototype._resetNavToolBarButtons =
994 function(view) {
995 
996 	var lv;
997     if (view) {
998         lv = this._view[view];
999     } else {
1000         lv = this.getListView();
1001         view = this._currentViewId;
1002     }
1003 	if (!lv) { return; }
1004 
1005 	if (lv._isPageless) {
1006 		this._setItemCountText();
1007 	}
1008 
1009 	if (!this._navToolBar[view]) { return; }
1010 
1011 	this._navToolBar[view].enable(ZmOperation.PAGE_BACK, lv.offset > 0);
1012 
1013 	// determine if we have more cached items to show (in case hasMore is wrong)
1014 	var hasMore = false;
1015 	if (this._list) {
1016 		hasMore = this._list.hasMore();
1017 		if (!hasMore && ((lv.offset + lv.getLimit()) < this._list.size())) {
1018 			hasMore = true;
1019 		}
1020 	}
1021 
1022 	this._navToolBar[view].enable(ZmOperation.PAGE_FORWARD, hasMore);
1023 
1024 	this._navToolBar[view].setText(this._getNavText(view));
1025 };
1026 
1027 /**
1028  * @private
1029  */
1030 ZmListController.prototype.enablePagination =
1031 function(enabled, view) {
1032 
1033 	if (!this._navToolBar[view]) { return; }
1034 
1035 	if (enabled) {
1036 		this._resetNavToolBarButtons(view);
1037 	} else {
1038 		this._navToolBar[view].enable([ZmOperation.PAGE_BACK, ZmOperation.PAGE_FORWARD], false);
1039 	}
1040 };
1041 
1042 /**
1043  * @private
1044  */
1045 ZmListController.prototype._getNavText =
1046 function(view) {
1047 
1048 	var se = this._getNavStartEnd(view);
1049 	if (!se) { return ""; }
1050 
1051     var size  = se.size;
1052     var msg   = "";
1053     if (size === 0) {
1054         msg = AjxMessageFormat.format(ZmMsg.navTextNoItems, ZmMsg[ZmApp.NAME[ZmApp.TASKS]]);
1055     } else if (size === 1) {
1056         msg = AjxMessageFormat.format(ZmMsg.navTextOneItem, ZmMsg[ZmItem.MSG_KEY[ZmItem.TASK]]);
1057     } else {
1058         // Multiple items
1059         var lv    = this._view[view];
1060         var limit = se.limit;
1061         if (size < limit) {
1062             // We have the exact size of the filtered items
1063             msg = AjxMessageFormat.format(ZmMsg.navTextWithTotal, [se.start, se.end, size]);
1064         } else {
1065             // If it's more than the limit, we don't have an exact count
1066             // available from the server
1067             var sizeText = this._getUpperLimitSizeText(size);
1068             var msgText = sizeText ? ZmMsg.navTextWithTotal : ZmMsg.navTextRange;
1069             msg = AjxMessageFormat.format(msgText, [se.start, se.end, sizeText]);
1070         }
1071     }
1072     return msg;
1073 };
1074 
1075 /**
1076  * @private
1077  */
1078 ZmListController.prototype._getNavStartEnd =
1079 function(view) {
1080 
1081 	var lv = this._view[view];
1082 	var limit = lv.getLimit();
1083 	var size = this._list ? this._list.size() : 0;
1084 
1085 	var start, end;
1086 	if (size > 0) {
1087 		start = lv.offset + 1;
1088 		end = Math.min(lv.offset + limit, size);
1089 	}
1090 
1091 	return (start && end) ? {start:start, end:end, size:size, limit:limit} : null;
1092 };
1093 
1094 /**
1095  * @private
1096  */
1097 ZmListController.prototype._getNumTotal =
1098 function() {
1099 
1100 	var folderId = this._getSearchFolderId();
1101 	if (folderId && (folderId != ZmFolder.ID_TRASH)) {
1102 		var folder = appCtxt.getById(folderId);
1103 		if (folder) {
1104 			return folder.numTotal;
1105 		}
1106 	}
1107 	return null;
1108 };
1109 
1110 /**
1111  * @private
1112  */
1113 ZmListController.prototype.getActionMenu =
1114 function() {
1115 	if (!this._actionMenu) {
1116 		this._initializeActionMenu();
1117 		//DBG.timePt("_initializeActionMenu");
1118 		this._resetOperations(this._actionMenu, 0);
1119 		//DBG.timePt("this._resetOperation(actionMenu)");
1120 	}
1121 	return this._actionMenu;
1122 };
1123 
1124 /**
1125  * Returns the context for the action menu created by this controller (used to create
1126  * an ID for the menu).
1127  * 
1128  * @private
1129  */
1130 ZmListController.prototype._getMenuContext =
1131 function() {
1132 	return this._app && this._app._name;
1133 };
1134 
1135 /**
1136  * @private
1137  */
1138 ZmListController.prototype._getItemCountText =
1139 function() {
1140 
1141 	var size = this._getItemCount();
1142 	// Size can be null or a number
1143 	if (!size) { return ""; }
1144 
1145 	var lv = this._view[this._currentViewId],
1146 		list = lv && lv._list,
1147 		type = lv._getItemCountType(),
1148 		total = this._getNumTotal(),
1149 		num = total || size,
1150 		countKey = 'type' + AjxStringUtil.capitalizeFirstLetter(ZmItem.MSG_KEY[type]),
1151         typeText = type ? AjxMessageFormat.format(ZmMsg[countKey], num) : "";
1152 
1153 	if (total && (size != total)) {
1154 		return AjxMessageFormat.format(ZmMsg.itemCount1, [size, total, typeText]);
1155 	} else {
1156 		var sizeText = this._getUpperLimitSizeText(size);
1157 		return AjxMessageFormat.format(ZmMsg.itemCount, [sizeText, typeText]);
1158 	}
1159 };
1160 
1161 ZmListController.prototype._getUpperLimitSizeText =
1162 function(size) {
1163     var sizeText = size;
1164     if (this._list.hasMore()) {
1165         //show 4+, 5+, 10+, 20+, 100+, 200+
1166         var granularity = size < 10 ? 1	: size < 100 ? 10 : 100;
1167         sizeText = (Math.floor(size / granularity)) * granularity + "+"; //round down to the chosen granularity
1168     }
1169     return sizeText;
1170 
1171 }
1172 
1173 
1174 
1175 ZmListController.prototype._getItemCount =
1176 function() {
1177 	var lv = this.getListView();
1178 	var list = lv && lv._list;
1179 	if (!list) {
1180         return 0;
1181     }
1182 	return list.size();
1183 };
1184 
1185 /**
1186  * Sets the text that shows the number of items, if we are pageless.
1187  * 
1188  * @private
1189  */
1190 ZmListController.prototype._setItemCountText =
1191 function(text) {
1192 
1193 	text = text || this._getItemCountText();
1194 	var field = this._itemCountText[this._currentViewId];
1195 	if (field) {
1196 		field.setText(text);
1197 	}
1198 };
1199 
1200 // Returns text that describes how many items are selected for action
1201 ZmListController.prototype._getItemSelectionCountText = function() {
1202 
1203 	var lv = this._view[this._currentViewId],
1204 		list = lv && lv._list,
1205 		type = lv._getItemCountType(),
1206 		num = lv.getSelectionCount(),
1207 		countKey = 'type' + AjxStringUtil.capitalizeFirstLetter(ZmItem.MSG_KEY[type]),
1208 		typeText = type ? AjxMessageFormat.format(ZmMsg[countKey], num) : "";
1209 
1210 	return num > 0 ? AjxMessageFormat.format(ZmMsg.itemSelectionCount, [num, typeText]) : '';
1211 };
1212 
1213 ZmListController.prototype._setItemSelectionCountText = function() {
1214 	this._setItemCountText(this._getItemSelectionCountText());
1215 };
1216 
1217 /**
1218  * Records total items and last item before we do any more searches. Adds a couple
1219  * params to the args for the list action method.
1220  *
1221  * @param {function}	actionMethod		the controller action method
1222  * @param {Array}		args				an arg list for above (except for items arg)
1223  * @param {Hash}		params				the params that will be passed to list action method
1224  * @param {closure}		allDoneCallback		the callback to run after all items processed
1225  * 
1226  * @private
1227  */
1228 ZmListController.prototype._setupContinuation =
1229 function(actionMethod, args, params, allDoneCallback, notIdsOnly) {
1230 
1231 	// need to use AjxCallback here so we can prepend items arg when calling it
1232 	var actionCallback = new AjxCallback(this, actionMethod, args);
1233 	params.finalCallback = this._continueAction.bind(this, {actionCallback:actionCallback, allDoneCallback:allDoneCallback, notIdsOnly: notIdsOnly});
1234 	
1235 	params.count = this._continuation.count;
1236 	params.idsOnly = !notIdsOnly;
1237 
1238 	if (!this._continuation.lastItem) {
1239 		this._continuation.lastItem = params.list.getVector().getLast();
1240 		this._continuation.totalItems = params.list.size();
1241 	}
1242 };
1243 
1244 /**
1245  * See if we are performing an action on all items, including ones that match the current search
1246  * but have not yet been retrieved. If so, keep doing searches and performing the action on the
1247  * results, until there are no more results.
1248  *
1249  * The arguments in the action callback should be those after the initial 'items' argument. The
1250  * array of items retrieved by the search is prepended to the callback's argument list before it
1251  * is run.
1252  *
1253  * @param {Hash}		params				a hash of parameters
1254  * @param {AjxCallback}	actionCallback		the callback with action to be performed on search results
1255  * @param {closure} 	allDoneCallback		the callback to run when we're all done
1256  * @param {Hash}		actionParams		the params from <code>ZmList._itemAction</code>, added when this is called
1257  * 
1258  * @private
1259  */
1260 ZmListController.prototype._continueAction =
1261 function(params, actionParams) {
1262 
1263 	var lv = this._view[this._currentViewId];
1264 	var cancelled = actionParams && actionParams.cancelled;
1265 	var contResult = this._continuation.result;
1266 	var hasMore = contResult ? contResult.getAttribute("more") : (this._list ? this._list.hasMore() : false);
1267 	DBG.println("sa", "lv.allSelected: " + lv.allSelected + ", hasMore: " + hasMore);
1268 	if (lv.allSelected && hasMore && !cancelled) {
1269 		var cs = this._currentSearch;
1270 		var limit = ZmListController.CONTINUATION_SEARCH_ITEMS;
1271 		var searchParams = {
1272 			query:		this.getSearchString(),
1273 			queryHint:	this.getSearchStringHint(),
1274 			types:		cs.types,
1275 			sortBy:		cs.sortBy,
1276 			limit:		limit,
1277 			idsOnly:	!params.notIdsOnly
1278 		};
1279 
1280 		var list = contResult ? contResult.getResults() : this._list.getArray();
1281 		var lastItem = this._continuation.lastItem;
1282 		if (!lastItem) {
1283 			lastItem = list && list[list.length - 1];
1284 		}
1285 		if (lastItem) {
1286 			searchParams.lastId = lastItem.id;
1287 			searchParams.lastSortVal = lastItem.sf;
1288 			DBG.println("sa", "***** continuation search: " + searchParams.query + " --- " + [lastItem.id, lastItem.sf].join("/"));
1289 		} else {
1290 			searchParams.offset = limit + (this._continuation.search ? this._continuation.search.offset : 0);
1291 		}
1292 
1293 		this._continuation.count = actionParams.numItems;
1294 		if (!this._continuation.totalItems) {
1295 			this._continuation.totalItems = list.length;
1296 		}
1297 
1298 		this._continuation.search = new ZmSearch(searchParams);
1299 		var respCallback = this._handleResponseContinueAction.bind(this, params.actionCallback);
1300 		appCtxt.getSearchController().redoSearch(this._continuation.search, true, null, respCallback);
1301 	} else {
1302 		DBG.println("sa", "end of continuation");
1303 		if (contResult) {
1304 			if (lv.allSelected) {
1305 				// items beyond page were acted on, give user a total count
1306 				if (actionParams.actionTextKey) {
1307 					var type = contResult.type;
1308 					if (type === ZmId.SEARCH_MAIL) {
1309 						type = this._list.type; //get the specific CONV/MSG type instead of the "searchFor" "MAIL".
1310 					}
1311 					actionParams.actionSummary = ZmList.getActionSummary({
1312 						actionTextKey:  actionParams.actionTextKey,
1313 						numItems:       this._continuation.totalItems,
1314 						type:           type,
1315 						actionArg:      actionParams.actionArg
1316 					});
1317 				}
1318 				lv.deselectAll();
1319 			}
1320 			this._continuation = {count:0, totalItems:0};
1321 		}
1322 		if (params.allDoneCallback) {
1323 			params.allDoneCallback();
1324 		}
1325 
1326 		ZmListController.handleProgress({state:ZmListController.PROGRESS_DIALOG_CLOSE});
1327 		ZmBaseController.showSummary(actionParams.actionSummary, actionParams.actionLogItem, actionParams.closeChildWin);
1328 	}
1329 };
1330 
1331 /**
1332  * @private
1333  */
1334 ZmListController.prototype._handleResponseContinueAction =
1335 function(actionCallback, result) {
1336 
1337 	this._continuation.result = result.getResponse();
1338 	var items = this._continuation.result.getResults();
1339 	DBG.println("sa", "continuation search results: " + items.length);
1340 	if (items.isZmMailList) { //no idsOnly case
1341 		items = items.getArray();
1342 	}
1343 	if (items.length) {
1344 		this._continuation.lastItem = items[items.length - 1];
1345 		this._continuation.totalItems += items.length;
1346 		DBG.println("sa", "continuation last item: " + this._continuation.lastItem.id);
1347 		actionCallback.args = actionCallback.args || [];
1348 		actionCallback.args.unshift(items);
1349 		DBG.println("sa", "calling continuation action on search results");
1350 		actionCallback.run();
1351 	} else {
1352 		DBG.println(AjxDebug.DBG1, "Continuation with empty search results!");
1353 	}
1354 };
1355 
1356 /**
1357  * @private
1358  */
1359 ZmListController.prototype._checkItemCount =
1360 function() {
1361 	var lv = this._view[this._currentViewId];
1362 	lv._checkItemCount();
1363 	lv._handleResponseCheckReplenish(true);
1364 };
1365 
1366 // Returns true if this controller supports sorting its items
1367 ZmListController.prototype.supportsSorting = function() {
1368     return true;
1369 };
1370 
1371 // Returns true if this controller supports alternatively grouped list views
1372 ZmListController.prototype.supportsGrouping = function() {
1373     return false;
1374 };
1375