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  * Creates a conversation.
 26  * @constructor
 27  * @class
 28  * This class represents a conversation, which is a collection of mail messages
 29  * which have the same subject.
 30  *
 31  * @param {int}	id		a unique ID
 32  * @param {ZmMailList}		list		a list that contains this conversation
 33  * 
 34  * @extends		ZmMailItem
 35  */
 36 ZmConv = function(id, list) {
 37 
 38 	ZmMailItem.call(this, ZmItem.CONV, id, list);
 39 	
 40 	// conversations are always sorted by date desc initially
 41 	this._sortBy = ZmSearch.DATE_DESC;
 42 	this._listChangeListener = new AjxListener(this, this._msgListChangeListener);
 43 	this.folders = {};
 44 	this.msgFolder = {};
 45 };
 46 
 47 ZmConv.prototype = new ZmMailItem;
 48 ZmConv.prototype.constructor = ZmConv;
 49 
 50 ZmConv.prototype.isZmConv = true;
 51 ZmConv.prototype.toString = function() { return "ZmConv"; };
 52 
 53 // Public methods
 54 
 55 /**
 56  * Creates a conv from its JSON representation.
 57  * 
 58  * @param	{Object}	node		the node
 59  * @param	{Hash}		args		a hash of arguments
 60  * @return	{ZmConv}		the conversation
 61  */
 62 ZmConv.createFromDom =
 63 function(node, args) {
 64 	var conv = new ZmConv(node.id, args.list);
 65 	conv._loadFromDom(node);
 66 	return conv;
 67 };
 68 
 69 /**
 70  * Creates a conv from msg data.
 71  * 
 72  * @param	{ZmMailMsg}		msg		the message
 73  * @param	{Hash}		args		a hash of arguments
 74  * @return	{ZmConv}		the conversation
 75  */
 76 ZmConv.createFromMsg =
 77 function(msg, args) {
 78 	var conv = new ZmConv(msg.cid, args.list);
 79 	conv._loadFromMsg(msg);
 80 	return conv;
 81 };
 82 
 83 /**
 84  * Ensures that the requested range of msgs is loaded, getting them from the server if needed.
 85  * Because the list of msgs returned by the server contains info about which msgs matched the
 86  * search, we need to be careful about caching those msgs within the conv. This load function
 87  * should be used when in a search context, for example when expanding a conv that is the result
 88  * of a search.
 89  *
 90  * @param {Hash}		params						a hash of parameters:
 91  * @param {String}		params.query				the query used to retrieve this conv
 92  * @param {constant}	params.sortBy				the sort constraint
 93  * @param {int}			params.offset				the position of first msg to return
 94  * @param {int}			params.limit				the number of msgs to return
 95  * @param {Boolean}		params.getHtml				if <code>true</code>, return HTML part for inlined msg
 96  * @param {String}		params.fetch				which msg bodies to fetch (see soap.txt under SearchConvRequest)
 97  * @param {Boolean}		params.markRead				if <code>true</code>, mark that msg read
 98  * @param {boolean}		params.needExp				if not <code>false</code>, have server check if addresses are DLs
 99  * @param {AjxCallback}	callback					the callback to run with results
100  */
101 ZmConv.prototype.load =
102 function(params, callback) {
103 
104 	params = params || {};
105 	var ctlr = appCtxt.getCurrentController();
106 	var query = params.query;
107 	if (!query) {
108 		query = (ctlr && ctlr.getSearchString) 
109 			? ctlr.getSearchString()
110 			: appCtxt.get(ZmSetting.INITIAL_SEARCH);
111 	}
112 	var queryHint = params.queryHint;
113 	if (!queryHint) {
114 		queryHint = (ctlr && ctlr.getSearchStringHint)
115 			? ctlr.getSearchStringHint() : "";
116 	}
117 	var sortBy = params.sortBy || ZmSearch.DATE_DESC;
118 	var offset = params.offset || 0;
119 	var limit = params.limit || appCtxt.get(ZmSetting.CONVERSATION_PAGE_SIZE);
120 
121 	var doSearch = true;
122 	if (this._loaded && this._expanded && this.msgs && this.msgs.size() && !params.forceLoad) {
123 		var size = this.msgs.size();
124 		if (this._sortBy != sortBy || this._query != query || (size != this.numMsgs && !offset)) {
125 			this.msgs.clear();
126 		} else if (!this.msgs.hasMore() || offset + limit <= size) {
127 			doSearch = false;	// we can use cached msg list
128 		}
129 	}
130 	if (!doSearch) {
131 		if (callback) {
132 			callback.run(this._createResult());
133 		}
134 	} else {
135 		this._sortBy = sortBy;
136 		this._query = query;
137 		this._offset = offset;
138 		this._limit = limit;
139 
140 		var searchParams = {
141 			query: query,
142 			queryHint: queryHint,
143 			types: (AjxVector.fromArray([ZmItem.MSG])),
144 			sortBy: sortBy,
145 			offset: offset,
146 			limit: limit,
147 			getHtml: (params.getHtml || this.isDraft || appCtxt.get(ZmSetting.VIEW_AS_HTML)),
148 			accountName: (appCtxt.multiAccounts && this.getAccount().name)
149 		};
150 
151 		var search = this.search = new ZmSearch(searchParams),
152 			fetch = (params.fetch === true) ? ZmSetting.CONV_FETCH_UNREAD_OR_FIRST : params.fetch || ZmSetting.CONV_FETCH_NONE;
153 
154 		var needExp = fetch !== ZmSetting.CONV_FETCH_NONE;
155 		var	convParams = {
156 			cid:		this.id,
157 			callback:	(new AjxCallback(this, this._handleResponseLoad, [params, callback, needExp])),
158 			fetch:      fetch,
159 			markRead:	params.markRead,
160 			noTruncate:	params.noTruncate,
161 			needExp:	needExp
162 		};
163 		search.getConv(convParams);
164 	}
165 };
166 
167 ZmConv.prototype._handleResponseLoad =
168 function(params, callback, expanded, result) {
169 	var results = result.getResponse();
170 	if (!params.offset) {
171 		this.msgs = results.getResults(ZmItem.MSG);
172 		this.msgs.convId = this.id;
173 		this.msgs.addChangeListener(this._listChangeListener);
174 		this.msgs.setHasMore(results.getAttribute("more"));
175 		this._loaded = true;
176 		this._expanded = expanded;
177 	}
178 	if (callback) {
179 		callback.run(result);
180 	}
181 };
182 
183 /**
184  * This method supports ZmZimletBase::getMsgsForConv. It loads *all* of this conv's
185  * messages, including their content. Note that it is not search-based, and uses
186  * GetConvRequest rather than SearchConvRequest.
187  * 
188  * @param {Hash}			params				a hash of parameters
189  * @param {Boolean}			params.fetchAll		if <code>true</code>, fetch content of all msgs
190  * @param {AjxCallback}		callback			the callback
191  * @param {ZmBatchCommand}	batchCmd			the batch cmd that contains this request
192  */
193 ZmConv.prototype.loadMsgs =
194 function(params, callback, batchCmd) {
195 
196 	params = params || {};
197 	var jsonObj = {GetConvRequest:{_jsns:"urn:zimbraMail"}};
198 	var request = jsonObj.GetConvRequest;
199 	var c = request.c = {
200 		id:		this.id,
201 		needExp:	true,
202 		html:	(params.getHtml || this.isDraft || appCtxt.get(ZmSetting.VIEW_AS_HTML))
203 	};
204 	if (params.fetchAll) {
205 		c.fetch = "all";
206 	}
207 	ZmMailMsg.addRequestHeaders(c);
208 
209 	// never pass "undefined" as arg to a callback!
210 	var respCallback = this._handleResponseLoadMsgs.bind(this, callback || null);
211 	if (batchCmd) {
212 		batchCmd.addRequestParams(jsonObj, respCallback);
213 	} else {
214 		appCtxt.getAppController().sendRequest({jsonObj:jsonObj, asyncMode:true, callback:respCallback});
215 	}
216 };
217 
218 ZmConv.prototype._handleResponseLoadMsgs =
219 function(callback, result) {
220 
221 	var resp = result.getResponse().GetConvResponse.c[0];
222 	this.msgIds = [];
223 
224 	if (!this.msgs) {
225 		// create new msg list
226 		this.msgs = new ZmMailList(ZmItem.MSG, this.search);
227 		this.msgs.convId = this.id;
228 		this.msgs.addChangeListener(this._listChangeListener);
229 	}
230 	else {
231 		//don't recreate if it already exists, so we don't lose listeners.. (see ZmConvView2.prototype.set)
232 		this.msgs.removeAllItems();
233 	}
234 	if (this.search && !this.msgs.search) {
235 		this.msgs.search = this.search;
236 	}
237 	this.msgs.setHasMore(false);
238 	this._loaded = true;
239 
240 	var len = resp.m.length;
241 	//going from last to first since GetConvRequest returns the msgs in order of creation (older first) but we keep things newer first.
242 	for (var i = len - 1; i >= 0; i--) {
243 		var msgNode = resp.m[i];
244 		this.msgIds.push(msgNode.id);
245 		this.msgFolder[msgNode.id] = msgNode.l;
246 		msgNode.su = resp.su;
247 		// construct ZmMailMsg's so they get cached
248 		var msg = ZmMailMsg.createFromDom(msgNode, {list: this.msgs});
249 		this.msgs.add(msg);
250 	}
251 
252 	if (callback) { callback.run(result); }
253 };
254 
255 /**
256  * Adds the message at the given index.
257  *
258  * @param	{ZmMailMsg}		msg		the message to add
259  * @param	{int}			index	where to add it
260  */
261 ZmConv.prototype.addMsg =
262 function(msg, index) {
263 
264 	if (!this.msgs) {
265 		this.msgs = new ZmMailList(ZmItem.MSG, this.search);
266 		this.msgs.convId = this.id;
267 		this.msgs.addChangeListener(this._listChangeListener);
268 		this.msgs.setHasMore(false);
269 	}
270 	if (this.search && !this.msgs.search) {
271 		this.msgs.search = this.search;
272 	}
273 	this.msgs.add(msg, index);
274 	this.msgIds = [];
275 	var a = this.msgs.getArray();
276 	for (var i = 0, len = a.length; i < len; i++) {
277 		this.msgIds.push(a[i].id);
278 	}
279 	this.msgFolder[msg.id] = msg.folderId;
280 };
281 
282 /**
283  * Removes the message.
284  * 
285  * @param	{ZmMailMsg}		msg		the message to remove
286  */
287 ZmConv.prototype.removeMsg =
288 function(msg) {
289 	if (this.msgs) {
290 		this.msgs.remove(msg, true);
291 	}
292 	if (this.msgIds && this.msgIds.length) {
293 		AjxUtil.arrayRemove(this.msgIds, msg.id);
294 	}
295 	delete this.msgFolder[msg.id];
296 };
297 
298 ZmConv.prototype.canAddTag =
299 function(tagName) {
300 	if (!this.msgs) {
301 		return ZmItem.prototype.canAddTag.call(this, tagName);
302 	}
303 	var msgs = this.msgs.getArray();
304 	for (var i = 0; i < msgs.length; i++) {
305 		var msg = msgs[i];
306 		if (msg.canAddTag(tagName)) {
307 			return true;
308 		}
309 	}
310 	return false;
311 };
312 
313 ZmConv.prototype.mute =
314 function() {
315     this.isMute = true;
316     if(this.msgs) {
317         var msgs = this.msgs.getArray();
318 		for (var i = 0; i < msgs.length; i++) {
319 			var msg = msgs[i];
320 			msg.mute();
321 		}
322     }
323 };
324 
325 ZmConv.prototype.unmute =
326 function() {
327     this.isMute = false;
328     if(this.msgs) {
329         var msgs = this.msgs.getArray();
330 		for (var i = 0; i < msgs.length; i++) {
331 			var msg = msgs[i];
332 			msg.unmute();
333 		}
334     }
335 };
336 
337 /**
338  * Gets the mute/unmute icon.
339  *
340  * @return	{String}	the icon
341  */
342 ZmConv.prototype.getMuteIcon =
343 function() {
344 	return this.isMute ? "Mute" : "Unmute";
345 };
346 
347 
348 ZmConv.prototype.clear =
349 function() {
350 	if (this.isInUse) {
351 		return;
352 	}
353 	if (this.msgs) {
354 		this.msgs.clear();
355 		this.msgs.removeChangeListener(this._listChangeListener);
356 		this.msgs = null;
357 	}
358 	this.msgIds = [];
359 	this.folders = {};
360 	this.msgFolder = {};
361 	
362 	ZmMailItem.prototype.clear.call(this);
363 };
364 
365 /**
366  * Checks if the conversation is read only. Returns false if it cannot be determined.
367  * 
368  * @return	{Boolean}	<code>true</code> if the conversation is read only
369  */
370 ZmConv.prototype.isReadOnly =
371 function() {
372 	
373 	if (this._loaded && this.msgs && this.msgs.size()) {
374 		// conv has been loaded, check each msg
375 		var msgs = this.msgs.getArray();
376 		for (var i = 0; i < msgs.length; i++) {
377 			if (msgs[i].isReadOnly()) {
378 				return true;
379 			}
380 		}
381 	}
382 	else {
383 		// conv has not been loaded, see if it's constrained to a folder
384 		var folderId = this.getFolderId();
385 		var folder = folderId && appCtxt.getById(folderId);
386 		return !!(folder && folder.isReadOnly());
387 	}
388 	return false;
389 };
390 
391 /**
392  * Checks if this conversation has a message that matches the given search.
393  * If we're not able to tell whether a msg matches, we return the given default value.
394  *
395  * @param {ZmSearch}	search			the search to match against
396  * @param {Object}	    defaultValue	the value to return if search is not matchable or conv not loaded
397  * @return	{Boolean}	<code>true</code> if this conversation has a matching message
398  */
399 ZmConv.prototype.hasMatchingMsg =
400 function(search, defaultValue) {
401 
402 	var msgs = this.msgs && this.msgs.getArray(),
403 		hasUnknown = false;
404 
405 	if (msgs && msgs.length > 0) {
406 		for (var i = 0; i < msgs.length; i++) {
407 			var msg = msgs[i],
408 				msgMatches = search.matches(msg);
409 
410 			if (msgMatches && !msg.ignoreJunkTrash() && this.folders[msg.folderId]) {
411 				return true;
412 			}
413 			else if (msgMatches === null) {
414 				hasUnknown = true;
415 			}
416 		}
417 	}
418 
419 	return hasUnknown ? !!defaultValue : false;
420 };
421 
422 ZmConv.prototype.containsMsg =
423 function(msg) {
424 	return this.msgIds && AjxUtil.arrayContains(this.msgIds, msg.id);
425 };
426 
427 ZmConv.prototype.ignoreJunkTrash =
428 function() {
429 	return Boolean((this.numMsgs == 1) && this.folders &&
430 				   ((this.folders[ZmFolder.ID_SPAM] && !appCtxt.get(ZmSetting.SEARCH_INCLUDES_SPAM)) ||
431 			 	    (this.folders[ZmFolder.ID_TRASH] && !appCtxt.get(ZmSetting.SEARCH_INCLUDES_TRASH))));
432 };
433 
434 ZmConv.prototype.getAccount =
435 function() {
436     // pull out the account from the fully-qualified ID
437 	if (!this.account) {
438         var folderId = this.getFolderId();
439         var folder = folderId && appCtxt.getById(folderId);
440         // make sure current folder is not remote folder
441         // in that case getting account from parseID will fail if
442         // the shared account is also configured in ZD
443         if (!(folder && folder._isRemote)) {
444             this.account = ZmOrganizer.parseId(this.id).account;
445         }
446     }
447 
448     // fallback on the active account if account not found from parsed ID (most
449     // likely means this is a conv inside a shared folder of the active acct)
450     if (!this.account) {
451         this.account = appCtxt.getActiveAccount();
452     }
453     return this.account;
454 
455 };
456 
457 /**
458 * Handles a modification notification.
459 * TODO: Bundle MODIFY notifs (should bubble up to parent handlers as well)
460 *
461 * @param obj		item with the changed attributes/content
462 * 
463 * @private
464 */
465 ZmConv.prototype.notifyModify =
466 function(obj, batchMode) {
467 	var fields = {};
468 	// a conv's ID can change if it's a virtual conv becoming real; 'this' will be
469 	// the old conv; if we can, we switch to using the new conv, which will be more
470 	// up to date; the new conv will be available if it was received via search results
471 	if (obj._newId != null) {
472 		var conv = appCtxt.getById(obj._newId) || this;
473 		conv._oldId = this.id;
474 		conv.id = obj._newId;
475 		appCtxt.cacheSet(conv._oldId);
476 		appCtxt.cacheSet(conv.id, conv);
477 		conv.msgs = conv.msgs || this.msgs;
478 		if (conv.msgs) {
479 			conv.msgs.convId = conv.id;
480 			var a = conv.msgs.getArray();
481 			for (var i = 0; i < a.length; i++) {
482 				a[i].cid = conv.id;
483 			}
484 		}
485 		conv.folders = AjxUtil.hashCopy(this.folders);
486 		if (conv.list && conv._oldId && conv.list._idHash[conv._oldId]) {
487 			delete conv.list._idHash[conv._oldId];
488 			conv.list._idHash[conv.id] = conv;
489 		}
490 		fields[ZmItem.F_ID] = true;
491 		conv._notify(ZmEvent.E_MODIFY, {fields : fields});
492 	}
493 	if (obj.n != null) {
494 		this.numMsgs = obj.n;
495 		fields[ZmItem.F_SIZE] = true;
496 		this._notify(ZmEvent.E_MODIFY, {fields : fields});
497 	}
498 
499 	return ZmMailItem.prototype.notifyModify.apply(this, arguments);
500 };
501 
502 /**
503  * Checks if any of the msgs within this conversation has the given value for
504  * the given flag. If the conv hasn't been loaded, looks at the conv-level flag.
505  *
506  * @param {constant}	flag		the flag (see <code>ZmItem.FLAG_</code> constants)
507  * @param {Boolean}	value		the test value
508  * @return	{Boolean}	<code>true</code> if the flag exists
509  */
510 ZmConv.prototype.hasFlag =
511 function(flag, value) {
512 	if (!this.msgs) {
513 		return (this[ZmItem.FLAG_PROP[flag]] == value);
514 	}
515 	var msgs = this.msgs.getArray();
516 	for (var j = 0; j < msgs.length; j++) {
517 		var msg = msgs[j];
518 		if (msg[ZmItem.FLAG_PROP[flag]] == value) {
519 			return true;
520 		}
521 	}
522 	return false;
523 };
524 
525 /**
526  * Checks to see if a change in the value of a msg flag changes the value of the conv's flag. That will happen
527  * for the first msg to get an off flag turned on, or when the last msg to have an on flag turns it off.
528  */
529 ZmConv.prototype._checkFlags = 
530 function(flags) {
531 
532 	var convOn = {};
533 	var msgsOn = {};
534 	for (var i = 0; i < flags.length; i++) {
535 		var flag = flags[i];
536 		if (!(flag == ZmItem.FLAG_FLAGGED || flag == ZmItem.FLAG_UNREAD || flag == ZmItem.FLAG_MUTE || flag == ZmItem.FLAG_ATTACH || flag == ZmItem.FLAG_PRIORITY)) { continue; }
537 		convOn[flag] = this[ZmItem.FLAG_PROP[flag]];
538 		msgsOn[flag] = this.hasFlag(flag, true);
539 	}			
540 	var doNotify = false;
541 	var flags = [];
542 	for (var flag in convOn) {
543 		if (convOn[flag] != msgsOn[flag]) {
544 			this[ZmItem.FLAG_PROP[flag]] = msgsOn[flag];
545 			flags.push(flag);
546 			doNotify = true;
547 		}
548 	}
549 
550 	if (doNotify) {
551 		this._notify(ZmEvent.E_FLAGS, {flags: flags});
552 	}
553 };
554 
555 /**
556  * Figure out if any tags have been added or removed by comparing what we have now with what
557  * our messages have.
558  * 
559  * @private
560  */
561 ZmConv.prototype._checkTags = 
562 function() {
563 	var newTags = {};
564 	var allTags = {};
565 	
566 	for (var tagId in this.tagHash) {
567 		allTags[tagId] = true;
568 	}
569 
570 	if (this.msgs) {
571 		var msgs = this.msgs.getArray();
572 		if (!(msgs && msgs.length)) { return; }
573 		for (var i = 0; i < msgs.length; i++) {
574 			for (var tagId in msgs[i].tagHash) {
575 				newTags[tagId] = true;
576 				allTags[tagId] = true;
577 			}
578 		}
579 
580 		var notify = false;
581 		for (var tagId in allTags) {
582 			if (!this.tagHash[tagId] && newTags[tagId]) {
583 				if (this.tagLocal(tagId, true)) {
584 					notify = true;
585 				}
586 			} else if (this.tagHash[tagId] && !newTags[tagId]) {
587 				if (this.tagLocal(tagId, false)) {
588 					notify = true;
589 				}
590 			}
591 		}
592 	}
593 
594 	if (notify) {
595 		this._notify(ZmEvent.E_TAGS);
596 	}
597 };
598 
599 ZmConv.prototype.moveLocal =
600 function(folderId) {
601 	if (this.folders) {
602 		delete this.folders;
603 	}
604 	this.folders = {};
605 	this.folders[folderId] = true;
606 };
607 
608 ZmConv.prototype.getMsgList =
609 function(offset, ascending, omit) {
610 	// this.msgs will not be set if the conv has not yet been loaded
611 	var list = this.msgs && this.msgs.getArray();
612 	var a = list ? (list.slice(offset || 0)) : [];
613 	if (omit) {
614 		var a1 = [];
615 		for (var i = 0; i < a.length; i++) {
616 			var msg = a[i];
617 			if (!(msg && msg.folderId && omit[msg.folderId])) {
618 				a1.push(msg);
619 			}
620 		}
621 		a = a1;
622 	}
623 	if (ascending) {
624 		a.reverse();
625 	}
626 	return a;
627 };
628 
629 ZmConv.prototype.getFolderId =
630 function() {
631 	return this.folderId || (this.list && this.list.search && this.list.search.folderId);
632 };
633 
634 /**
635  * Gets the first relevant msg of this conv, loading the conv msg list if necessary. If the
636  * msg itself hasn't been loaded we also load the conv. The conv load is a SearchConvRequest
637  * which fetches the content of the first msg and returns it via a callback. If no
638  * callback is provided, the conv will not be loaded - if it already has a msg list, the msg
639  * will come from there; otherwise, a skeletal msg with an ID is returned. Note that a conv
640  * always has at least one msg.
641  * 
642  * @param {Hash}	params	a hash of parameters
643  * @param {String}      params.query				the query used to retrieve this conv
644  * @param {constant}      params.sortBy			the sort constraint
645  * @param {int}	      params.offset			the position of first msg to return
646  * @param {int}	params.limit				the number of msgs to return
647  * @param {AjxCallback}	callback			the callback to run with results
648  * 
649  * @return	{ZmMailMsg}	the message
650  */
651 ZmConv.prototype.getFirstHotMsg =
652 function(params, callback) {
653 	
654 	var msg;
655 	params = params || {};
656 
657 	if (this.msgs && this.msgs.size()) {
658 		msg = this.msgs.getFirstHit(params.offset, params.limit, params.foldersToOmit);
659 	}
660 
661 	if (callback) {
662 		if (msg && msg._loaded && !params.forceLoad) {
663 			callback.run(msg);
664 		}
665 		else {
666 			var respCallback = this._handleResponseGetFirstHotMsg.bind(this, params, callback);
667 			params.fetch = ZmSetting.CONV_FETCH_FIRST;
668 			this.load(params, respCallback);
669 		}
670 	}
671 	else {
672 		// do our best to return a "realized" message by checking cache
673 		if (!msg && this.msgIds && this.msgIds.length) {
674 			var id = this.msgIds[0];
675 			msg = appCtxt.getById(id);
676 			if (!msg) {
677 				if (!this.msgs) {
678 					this.msgs = new ZmMailList(ZmItem.MSG);
679 					this.msgs.convId = this.id;
680 					this.msgs.addChangeListener(this._listChangeListener);
681 				}
682 				msg = new ZmMailMsg(id, this.msgs);
683 			}
684 		}
685 		return msg;
686 	}
687 };
688 
689 ZmConv.prototype._handleResponseGetFirstHotMsg = function(params, callback) {
690 
691 	var msg = this.msgs.getFirstHit(params.offset, params.limit, params.foldersToOmit);
692 	// should have a loaded msg
693 	if (msg && msg._loaded) {
694 		if (callback) {
695 			callback.run(msg);
696 		}
697 	}
698 	else {
699 		// desperate measures - get msg content from server
700 		if (!msg && this.msgIds && this.msgIds.length) {
701 			msg = new ZmMailMsg(this.msgIds[0]);
702 		}
703 		var respCallback = this._handleResponseLoadMsg.bind(this, msg, callback);
704 		msg.load({getHtml:params.getHtml, callback:respCallback});
705 	}
706 };
707 
708 ZmConv.prototype._handleResponseLoadMsg =
709 function(msg, callback) {
710 	if (msg && callback) {
711 		callback.run(msg);
712 	}
713 };
714 
715 ZmConv.prototype._loadFromDom =
716 function(convNode) {
717 
718 	this.numMsgs = convNode.n;
719 	this.date = convNode.d;
720 	this._parseFlagsOfMsgs(convNode.m);   // parse flags based on msgs
721 	this._parseTagNames(convNode.tn);
722 	if (convNode.e) {
723 		for (var i = 0; i < convNode.e.length; i++) {
724 			this._parseParticipantNode(convNode.e[i]);
725 		}
726 	}
727 	this.participantsElided = convNode.elided;
728 	this.subject = convNode.su;
729 	this.fragment = convNode.fr;
730 	this.sf = convNode.sf;
731 
732 	// note that the list of msg IDs in a search result is partial - only msgs that matched are included
733 	if (convNode.m) {
734 		this.msgIds = [];
735 		this.msgFolder = {};
736 		for (var i = 0, count = convNode.m.length; i < count; i++) {
737 			var msgNode = convNode.m[i];
738 			this.msgIds.push(msgNode.id);
739 			this.msgFolder[msgNode.id] = msgNode.l;
740 			this.folders[msgNode.l] = true;
741 		}
742 		if (count == 1) {
743 			var msgNode = convNode.m[0];
744 
745 			// bug 49067 - SearchConvResponse does not return the folder ID w/in
746 			// the msgNode as fully qualified so reset if this 1-msg conv was
747 			// returned by a simple folder search
748 			// TODO: if 85358 is fixed, we can remove this section
749 			var searchFolderId = this.list && this.list.search && this.list.search.folderId;
750 			if (searchFolderId) {
751 				this.folderId = searchFolderId;
752 				this.folders[searchFolderId] = true;
753 			} else if (msgNode.l) {
754 				this.folderId = msgNode.l;
755 				this.folders[msgNode.l] = true;
756 			}
757 			else {
758 				AjxDebug.println(AjxDebug.NOTIFY, "no folder added for conv");
759 			}
760 			if (msgNode.s) {
761 				this.size = msgNode.s;
762 			}
763 
764 			if (msgNode.autoSendTime) {
765 				var timestamp = parseInt(msgNode.autoSendTime);
766 				if (timestamp) {
767 					this.setAutoSendTime(new Date(timestamp));
768 				}
769 			}
770 		}
771 	}
772 
773 	// Grab the metadata, keyed off the section name
774 	if (convNode.meta) {
775 		this.meta = {};
776 		for (var i = 0; i < convNode.meta.length; i++) {
777 			var section = convNode.meta[i].section;
778 			this.meta[section] = {};
779 			this.meta[section]._attrs = {};
780 			for (a in convNode.meta[i]._attrs) {
781 				this.meta[section]._attrs[a] = convNode.meta[i]._attrs[a];
782 			}
783 		}
784 	}
785 };
786 
787 ZmConv.prototype._loadFromMsg =
788 function(msg) {
789 	this.date = msg.date;
790 	this.isFlagged = msg.isFlagged;
791 	this.isUnread = msg.isUnread;
792 	for (var i = 0; i < msg.tags.length; i++) {
793 		this.tagLocal(msg.tags[i], true);
794 	}
795 	var a = msg.participants ? msg.participants.getArray() : null;
796 	this.participants = new AjxVector();
797 	if (a && a.length) {
798 		for (var i = 0; i < a.length; i++) {
799 			var p = a[i];
800 			if ((msg.isDraft && p.type == AjxEmailAddress.TO) ||
801 				(!msg.isDraft && p.type == AjxEmailAddress.FROM)) {
802 				this.participants.add(p);
803 			}
804 		}
805 	}
806 	this.subject = msg.subject;
807 	this.fragment = msg.fragment;
808 	this.sf = msg.sf;
809 	this.msgIds = [msg.id];
810 	this.msgFolder[msg.id] = msg.folderId;
811 	//add a flag to redraw this conversation when additional information is available
812 	this.redrawConvRow = true;
813 };
814 
815 ZmConv.prototype._msgListChangeListener =
816 function(ev) {
817 	if (ev.type != ZmEvent.S_MSG) {	return; }
818 	if (ev.event == ZmEvent.E_TAGS || ev.event == ZmEvent.E_REMOVE_ALL) {
819 		this._checkTags();
820 	} else if (ev.event == ZmEvent.E_FLAGS) {
821 		this._checkFlags(ev.getDetail("flags"));
822 	} else if (ev.event == ZmEvent.E_DELETE || ev.event == ZmEvent.E_MOVE) {
823 		// a msg was moved or deleted, see if this conv's row should remain
824 		if (this.list && this.list.search && !this.hasMatchingMsg(this.list.search, true)) {
825             this.moveLocal(ev.item && ev.item.folderId);
826 			this._notify(ev.event);
827 		}
828 	}
829 };
830 
831 /**
832  * Returns a result created from this conv's data that looks as if it were the result
833  * of an actual SOAP request.
834  * 
835  * @private
836  */
837 ZmConv.prototype._createResult =
838 function() {
839 	var searchResult = new ZmSearchResult(this.search);
840 	searchResult.type = ZmItem.MSG;
841 	searchResult._results[ZmItem.MSG] = this.msgs;
842 	return new ZmCsfeResult(searchResult);
843 };
844 
845 // Updates the conversation fragment based on the newest message in the conversation, optionally ignoring an array of messages
846 ZmConv.prototype.updateFragment =
847 function(ignore) {
848 	var best;
849 	var size = this.msgs && this.msgs.size();
850 	if (size) {
851 		for (var j = size - 1; j >= 0; j--) {
852 			var candidate = this.msgs.get(j);
853 			if (ignore && AjxUtil.indexOf(ignore, candidate) != -1) { continue; }
854 			if (candidate.fragment && (!best || candidate.date > best.date)) {
855 				best = candidate;
856 			}
857 		}
858 	}
859 	if (best) {
860 		this.fragment = best.fragment;
861 	}
862 };
863 
864 /**
865  * Gets a vector of addresses of the given type.
866  *
867  * @param {constant}	type			an email address type
868  *
869  * @return	{AjxVector}	a vector of email addresses
870  */
871 ZmConv.prototype.getAddresses =
872 function(type) {
873 
874 	var p = this.participants ? this.participants.getArray() : [];
875 	var list = [];
876 	for (var i = 0, len = p.length; i < len; i++) {
877 		var addr = p[i];
878 		if (addr.type == type) {
879 			list.push(addr);
880 		}
881 	}
882 	return AjxVector.fromArray(list);
883 };
884 
885 /**
886  * Gets the status tool tip.
887  * 
888  * @return	{String}	the tool tip
889  */
890 ZmConv.prototype.getStatusTooltip =
891 function() {
892 	if (this.numMsgs === 1 && this.msgIds && this.msgIds.length > 0) {
893 		var msg = appCtxt.getById(this.msgIds[0]);
894 		if (msg) {
895 			return msg.getStatusTooltip();
896 		}
897 	}
898 
899 	var status = [];
900 
901 	// keep in sync with ZmMailMsg.prototype.getStatusTooltip
902 	if (this.isScheduled) {
903 		status.push(ZmMsg.scheduled);
904 	}
905 	if (this.isUnread) {
906 		status.push(ZmMsg.unread);
907 	}
908 	if (this.isReplied) {
909 		status.push(ZmMsg.replied);
910 	}
911 	if (this.isForwarded) {
912 		status.push(ZmMsg.forwarded);
913 	}
914 	if (this.isDraft) {
915 		status.push(ZmMsg.draft);
916 	} else if (this.isSent) {
917 		//sentAt is for some reason "sent", which is what we need.
918 		status.push(ZmMsg.sentAt);
919 	}
920 
921 	return status.join(", ");
922 };
923 
924 /**
925  * Returns the number of unread messages in this conversation.
926  */
927 ZmConv.prototype.getNumUnreadMsgs =
928 function() {
929 	var numUnread = 0;
930 	var msgs = this.getMsgList();
931 	if (msgs) {
932 		for (var i = 0, len = msgs.length; i < len; i++) {
933 			if (msgs[i].isUnread) {
934 				numUnread++;
935 			}
936 		}
937 		return numUnread;
938 	}
939 	return null;
940 };
941 
942 /**
943  * Parse flags based on which flags are in the messages we will display (which normally
944  * excludes messages in Trash or Junk).
945  *
946  * @param   [array]     msgs        msg nodes from search result
947  *
948  * @private
949  */
950 ZmConv.prototype._parseFlagsOfMsgs = function(msgs) {
951 
952 	// use search from list since it's not yet set in controller
953 	var ignore = ZmMailApp.getFoldersToOmit(this.list && this.list.search),
954 		msg, len = msgs ? msgs.length : 0, i,
955 		flags = {};
956 
957 	for (i = 0; i < len; i++) {
958 		msg = msgs[i];
959 		if (!ignore[msg.l]) {
960 			var msgFlags = msg.f && msg.f.split(''),
961 				len1 = msgFlags ? msgFlags.length : 0, j;
962 
963 			for (j = 0; j < len1; j++) {
964 				flags[msgFlags[j]] = true;
965 			}
966 		}
967 	}
968 
969 	this.flags = AjxUtil.keys(flags).join('');
970 	ZmItem.prototype._parseFlags.call(this, this.flags);
971 };
972