1 /* 2 * ***** BEGIN LICENSE BLOCK ***** 3 * Zimbra Collaboration Suite Web Client 4 * Copyright (C) 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) 2011, 2012, 2013, 2014, 2015, 2016 Synacor, Inc. All Rights Reserved. 21 * ***** END LICENSE BLOCK ***** 22 */ 23 24 /** 25 * @class 26 * This class provides a central area for managing email recipient fields. It is not a control, 27 * and does not exist within the widget hierarchy. 28 * 29 * @param {hash} params a hash of params: 30 * @param {function} resetContainerSizeMethod callback for when size needs to be adjusted 31 * @param {function} enableContainerInputs callback for enabling/disabling input fields 32 * @param {function} reenter callback to enable design mode 33 * @param {AjxListener} contactPopdownListener listener called when contact picker pops down 34 * @param {string} contextId ID of owner (used for autocomplete list) 35 */ 36 ZmRecipients = function(params) { 37 38 this._divId = {}; 39 this._buttonTdId = {}; 40 this._fieldId = {}; 41 this._using = {}; 42 this._button = {}; 43 this._field = {}; 44 this._divEl = {}; 45 this._addrInputField = {}; 46 47 this._resetContainerSize = params.resetContainerSizeMethod; 48 this._enableContainerInputs = params.enableContainerInputs; 49 this._reenter = params.reenter; 50 this._contactPopdownListener = params.contactPopdownListener; 51 this._contextId = params.contextId; 52 53 this._bubbleOps = {}; 54 this._bubbleOps[AjxEmailAddress.TO] = ZmOperation.MOVE_TO_TO; 55 this._bubbleOps[AjxEmailAddress.CC] = ZmOperation.MOVE_TO_CC; 56 this._bubbleOps[AjxEmailAddress.BCC] = ZmOperation.MOVE_TO_BCC; 57 this._opToField = {}; 58 this._opToField[ZmOperation.MOVE_TO_TO] = AjxEmailAddress.TO; 59 this._opToField[ZmOperation.MOVE_TO_CC] = AjxEmailAddress.CC; 60 this._opToField[ZmOperation.MOVE_TO_BCC] = AjxEmailAddress.BCC; 61 }; 62 63 ZmRecipients.OP = {}; 64 ZmRecipients.OP[AjxEmailAddress.TO] = ZmId.CMP_TO; 65 ZmRecipients.OP[AjxEmailAddress.CC] = ZmId.CMP_CC; 66 ZmRecipients.OP[AjxEmailAddress.BCC] = ZmId.CMP_BCC; 67 68 ZmRecipients.BAD = "_bad_addrs_"; 69 70 71 ZmRecipients.prototype.attachFromSelect = 72 function(fromSelect) { 73 this._fromSelect = fromSelect; 74 } 75 76 ZmRecipients.prototype.createRecipientIds = 77 function(htmlElId, typeStr) { 78 var ids = {}; 79 var components = ["row", "picker", "control", "cell"]; 80 for (var i = 0; i < components.length; i++) { 81 ids[components[i]] = [htmlElId, typeStr, components[i]].join("_") 82 } 83 return ids; 84 } 85 86 87 ZmRecipients.prototype.createRecipientHtml = 88 function(parent, viewId, htmlElId, fieldNames) { 89 90 this._fieldNames = fieldNames; 91 var contactsEnabled = appCtxt.get(ZmSetting.CONTACTS_ENABLED); 92 var galEnabled = appCtxt.get(ZmSetting.GAL_ENABLED); 93 94 // init autocomplete list 95 if (contactsEnabled || galEnabled || appCtxt.isOffline) { 96 var params = { 97 dataClass: appCtxt.getAutocompleter(), 98 matchValue: ZmAutocomplete.AC_VALUE_FULL, 99 keyUpCallback: this._acKeyupHandler.bind(this), 100 contextId: this._contextId 101 }; 102 this._acAddrSelectList = new ZmAutocompleteListView(params); 103 } 104 105 var isPickerEnabled = contactsEnabled || galEnabled || appCtxt.multiAccounts; 106 107 this._pickerButton = {}; 108 109 // process compose fields 110 for (var i = 0; i < fieldNames.length; i++) { 111 var type = fieldNames[i]; 112 var typeStr = AjxEmailAddress.TYPE_STRING[type]; 113 114 // save identifiers 115 var ids = this.createRecipientIds(htmlElId, typeStr); 116 this._divId[type] = ids.row; 117 this._buttonTdId[type] = ids.picker; 118 var inputId = this._fieldId[type] = ids.control; 119 var label = AjxMessageFormat.format(ZmMsg.addressFieldLabel, ZmMsg[AjxEmailAddress.TYPE_STRING[this._fieldNames[i]]]); 120 121 // save field elements 122 this._divEl[type] = document.getElementById(this._divId[type]); 123 var aifId; 124 var aifParams = { 125 parent: parent, 126 autocompleteListView: this._acAddrSelectList, 127 bubbleAddedCallback: this._bubblesChangedCallback.bind(this), 128 bubbleRemovedCallback: this._bubblesChangedCallback.bind(this), 129 bubbleMenuCreatedCallback: this._bubbleMenuCreated.bind(this), 130 bubbleMenuResetOperationsCallback: this._bubbleMenuResetOperations.bind(this), 131 inputId: inputId, 132 label: label, 133 type: type 134 } 135 var aif = this._addrInputField[type] = new ZmAddressInputField(aifParams); 136 aifId = aif._htmlElId; 137 aif.reparentHtmlElement(ids.cell); 138 139 // save field control 140 var fieldEl = this._field[type] = document.getElementById(this._fieldId[type]); 141 if (fieldEl) { 142 fieldEl.addrType = type; 143 fieldEl.supportsAutoComplete = true; 144 } 145 146 // create picker 147 if (isPickerEnabled) { 148 149 // bug 78318 - if GAL enabled but not contacts, we need some things defined to handle GAL search 150 if (!contactsEnabled) { 151 appCtxt.getAppController()._createApp(ZmApp.CONTACTS); 152 } 153 154 var pickerId = this._buttonTdId[type]; 155 var pickerEl = document.getElementById(pickerId); 156 if (pickerEl) { 157 var buttonId = ZmId.getButtonId(viewId, ZmRecipients.OP[type]); 158 var button = this._pickerButton[type] = new DwtButton({parent:parent, id:buttonId}); 159 button.setText(pickerEl.innerHTML); 160 button.replaceElement(pickerEl); 161 162 button.addSelectionListener(this.addressButtonListener.bind(this)); 163 button.addrType = type; 164 165 // autocomplete-related handlers 166 // Enable this even if contacts are not enabled, to provide GAL autoComplete 167 this._acAddrSelectList.handle(fieldEl, aifId); 168 169 this._button[type] = button; 170 } 171 } else { 172 // Mark the field, so that it will be sized properly in ZmAddressInputField._resizeInput. 173 // Otherwise, it is set to 30px wide, which makes it rather hard to type into. 174 fieldEl.supportsAutoComplete = false; 175 } 176 } 177 }; 178 179 ZmRecipients.prototype.reset = 180 function() { 181 182 // reset To/CC/BCC fields 183 for (var i = 0; i < this._fieldNames.length; i++) { 184 var type = this._fieldNames[i]; 185 var textarea = this._field[type]; 186 textarea.value = ""; 187 var addrInput = this._addrInputField[type]; 188 if (addrInput) { 189 addrInput.clear(); 190 } 191 } 192 }; 193 194 ZmRecipients.prototype.resetPickerButtons = 195 function(account) { 196 var ac = window.parentAppCtxt || window.appCtxt; 197 var isEnabled = ac.get(ZmSetting.CONTACTS_ENABLED, null, account) || 198 ac.get(ZmSetting.GAL_ENABLED, null, account); 199 200 for (var i in this._pickerButton) { 201 var button = this._pickerButton[i]; 202 button.setEnabled(isEnabled); 203 } 204 }; 205 206 ZmRecipients.prototype.setup = 207 function() { 208 // reset To/Cc/Bcc fields 209 if (this._field[AjxEmailAddress.TO]) { 210 this._showAddressField(AjxEmailAddress.TO, true, true, true); 211 } 212 if (this._field[AjxEmailAddress.CC]) { 213 this._showAddressField(AjxEmailAddress.CC, true, true, true); 214 } 215 if (this._field[AjxEmailAddress.BCC]) { 216 this._showAddressField(AjxEmailAddress.BCC, false, true, true); 217 } 218 }; 219 220 ZmRecipients.prototype.getPicker = 221 function(type) { 222 return this._pickerButton[type]; 223 }; 224 225 ZmRecipients.prototype.getField = 226 function(type) { 227 return document.getElementById(this._fieldId[type]); 228 }; 229 230 ZmRecipients.prototype.getUsing = 231 function(type) { 232 return this._using[type]; 233 }; 234 235 ZmRecipients.prototype.getACAddrSelectList = 236 function() { 237 return this._acAddrSelectList; 238 }; 239 240 ZmRecipients.prototype.getTabGroupMember = function() { 241 var tg = new DwtTabGroup('ZmRecipients'); 242 243 for (var i = 0; i < ZmMailMsg.COMPOSE_ADDRS.length; i++) { 244 var type = ZmMailMsg.COMPOSE_ADDRS[i]; 245 tg.addMember(this.getPicker(type)); 246 tg.addMember(this.getAddrInputField(type).getTabGroupMember()); 247 } 248 249 return tg; 250 }; 251 252 ZmRecipients.prototype.getAddrInputField = 253 function(type) { 254 return this._addrInputField[type]; 255 }; 256 257 // Adds the given addresses to the form. We need to add each address separately in case it's a DL. 258 ZmRecipients.prototype.addAddresses = 259 function(type, addrVec, used) { 260 261 var addrAdded = false; 262 used = used || {}; 263 var addrList = []; 264 var addrs = AjxUtil.toArray(addrVec); 265 if (addrs && addrs.length) { 266 for (var i = 0, len = addrs.length; i < len; i++) { 267 var addr = addrs[i]; 268 var email = addr.isAjxEmailAddress ? addr && addr.getAddress() : addr; 269 if (!email) { continue; } 270 email = email.toLowerCase(); 271 if (!used[email]) { 272 this.setAddress(type, addr); // add the bubble now 273 used[email] = true; 274 addrAdded = true; 275 } 276 } 277 } 278 return addrAdded; 279 }; 280 281 282 /** 283 * Sets an address field. 284 * 285 * @param type the address type 286 * @param addr the address string 287 * 288 * XXX: if addr empty, check if should hide field 289 * 290 * @private 291 */ 292 ZmRecipients.prototype.setAddress = 293 function(type, addr) { 294 295 addr = addr || ""; 296 297 var addrStr = addr.isAjxEmailAddress ? addr.toString() : addr; 298 299 //show first, so focus works on IE. 300 if (addrStr.length && !this._using[type]) { 301 this._using[type] = true; 302 this._showAddressField(type, true); 303 } 304 305 var addrInput = this._addrInputField[type]; 306 if (!addrStr) { 307 addrInput.clear(); 308 } 309 else { 310 if (addr.isAjxEmailAddress) { 311 var match = {isDL: addr.isGroup && addr.canExpand, email: addrStr}; 312 addrInput.addBubble({address:addrStr, match:match, skipNotify:true, noFocus:true}); 313 } 314 else { 315 this._setAddrFieldValue(type, addrStr); 316 } 317 } 318 }; 319 320 321 /** 322 * Gets the field values for each of the addr fields. 323 * 324 * @return {Array} an array of addresses 325 */ 326 ZmRecipients.prototype.getRawAddrFields = 327 function() { 328 var addrs = {}; 329 for (var i = 0; i < this._fieldNames.length; i++) { 330 var type = this._fieldNames[i]; 331 if (this._using[type]) { 332 addrs[type] = this.getAddrFieldValue(type); 333 } 334 } 335 return addrs; 336 }; 337 338 // returns address fields that are currently visible 339 ZmRecipients.prototype.getAddrFields = 340 function() { 341 var addrs = []; 342 for (var i = 0; i < this._fieldNames.length; i++) { 343 var type = this._fieldNames[i]; 344 if (this._using[type]) { 345 addrs.push(this._field[type]); 346 } 347 } 348 return addrs; 349 }; 350 351 352 // Grab the addresses out of the form. Optionally, they can be returned broken 353 // out into good and bad addresses, with an aggregate list of the bad ones also 354 // returned. If the field is hidden, its contents are ignored. 355 ZmRecipients.prototype.collectAddrs = 356 function() { 357 358 var addrs = {}; 359 addrs[ZmRecipients.BAD] = new AjxVector(); 360 for (var i = 0; i < this._fieldNames.length; i++) { 361 var type = this._fieldNames[i]; 362 363 if (!this._field[type]) { //this check is in case we don't have all fields set up (might be configurable. Didn't look deeply). 364 continue; 365 } 366 367 var val = this.getAddrFieldValue(type); 368 if (val.length == 0) { continue; } 369 val = val.replace(/[; ,]+$/, ""); // ignore trailing (and possibly extra) separators 370 var result = AjxEmailAddress.parseEmailString(val, type, false); 371 if (result.all.size() == 0) { continue; } 372 addrs.gotAddress = true; 373 addrs[type] = result; 374 if (result.bad.size()) { 375 addrs[ZmRecipients.BAD].addList(result.bad); 376 if (!addrs.badType) { 377 addrs.badType = type; 378 } 379 } 380 } 381 return addrs; 382 }; 383 384 385 ZmRecipients.prototype.getAddrFieldValue = 386 function(type) { 387 var addrInput = this._addrInputField[type]; 388 return addrInput ? addrInput.getValue() : ''; 389 }; 390 391 ZmRecipients.prototype.enableInputs = 392 function(bEnable) { 393 // disable input elements so they dont bleed into top zindex'd view 394 for (var i = 0; i < this._fieldNames.length; i++) { 395 this._field[this._fieldNames[i]].disabled = !bEnable; 396 } 397 }; 398 399 // Address buttons invoke contact picker 400 ZmRecipients.prototype.addressButtonListener = 401 function(ev, addrType) { 402 if (appCtxt.isWebClientOffline()) return; 403 404 var obj = ev ? DwtControl.getTargetControl(ev) : null; 405 if (this._enableContainerInputs) { 406 this._enableContainerInputs(false); 407 } 408 409 if (!this._contactPicker) { 410 AjxDispatcher.require("ContactsCore"); 411 var buttonInfo = []; 412 for (var i = 0; i < this._fieldNames.length; i++) { 413 buttonInfo[i] = { id: this._fieldNames[i], 414 label : ZmMsg[AjxEmailAddress.TYPE_STRING[this._fieldNames[i]]]}; 415 } 416 this._contactPicker = new ZmContactPicker(buttonInfo); 417 this._contactPicker.registerCallback(DwtDialog.OK_BUTTON, this._contactPickerOkCallback, this); 418 this._contactPicker.registerCallback(DwtDialog.CANCEL_BUTTON, this._contactPickerCancelCallback, this); 419 } 420 421 var curType = obj ? obj.addrType : addrType; 422 var addrList = {}; 423 for (var i = 0; i < this._fieldNames.length; i++) { 424 var type = this._fieldNames[i]; 425 addrList[type] = this._addrInputField[type].getAddresses(true); 426 } 427 if (this._contactPopdownListener) { 428 this._contactPicker.addPopdownListener(this._contactPopdownListener); 429 } 430 var str = (this._field[curType].value && !(addrList[curType] && addrList[curType].length)) 431 ? this._field[curType].value : ""; 432 433 var account; 434 if (appCtxt.multiAccounts && this._fromSelect) { 435 var addr = this._fromSelect.getSelectedOption().addr; 436 account = appCtxt.accountList.getAccountByEmail(addr.address); 437 } 438 this._contactPicker.popup(curType, addrList, str, account); 439 }; 440 441 442 443 444 // Private methods 445 446 // Show address field 447 ZmRecipients.prototype._showAddressField = 448 function(type, show, skipNotify, skipFocus) { 449 this._using[type] = show; 450 Dwt.setVisible(this._divEl[type], show); 451 this._setAddrFieldValue(type, ""); // bug fix #750 and #3680 452 this._field[type].noTab = !show; 453 this._addrInputField[type].noTab = !show; 454 if (this._pickerButton[type]) { 455 this._pickerButton[type].noTab = !show; 456 } 457 if (this._resetContainerSize) { 458 this._resetContainerSize(); 459 } 460 }; 461 462 ZmRecipients.prototype._acKeyupHandler = 463 function(ev, acListView, result, element) { 464 var key = DwtKeyEvent.getCharCode(ev); 465 // process any printable character or enter/backspace/delete keys 466 if (result && element && (ev.inputLengthChanged || 467 (DwtKeyEvent.IS_RETURN[key] || key === DwtKeyEvent.KEY_BACKSPACE || key === DwtKeyEvent.KEY_DELETE || 468 (AjxEnv.isMac && key === DwtKeyEvent.KEY_COMMAND)))) // bug fix #24670 469 { 470 element.value = element.value && element.value.replace(/;([^\s])/g, function(all, group){return "; "+group}) || ""; // Change ";" to "; " if it is not succeeded by a whitespace 471 } 472 }; 473 474 /** 475 * a callback that's called when bubbles are added or removed, since we need to resize the msg body in those cases. 476 */ 477 ZmRecipients.prototype._bubblesChangedCallback = 478 function() { 479 if (this._resetContainerSize) { 480 this._resetContainerSize(); // body size might change due to change in size of address field (due to new bubbles). 481 } 482 }; 483 484 ZmRecipients.prototype._bubbleMenuCreated = 485 function(addrInput, menu) { 486 487 this._bubbleActionMenu = menu; 488 if (this._fieldNames.length > 1) { 489 menu.addOp(ZmOperation.SEP); 490 var listener = new AjxListener(this, this._bubbleMove); 491 492 for (var i = 0; i < this._fieldNames.length; i++) { 493 var type = this._fieldNames[i]; 494 var op = this._bubbleOps[type]; 495 menu.addOp(op); 496 menu.addSelectionListener(op, listener); 497 } 498 } 499 }; 500 501 ZmRecipients.prototype._bubbleMenuResetOperations = 502 function(addrInput, menu) { 503 var sel = addrInput.getSelection(); 504 for (var i = 0; i < this._fieldNames.length; i++) { 505 var type = this._fieldNames[i]; 506 var op = this._bubbleOps[type]; 507 menu.enable(op, sel.length > 0 && (type != addrInput.type)); 508 } 509 }; 510 511 ZmRecipients.prototype._bubbleMove = 512 function(ev) { 513 514 var sourceInput = ZmAddressInputField.menuContext.addrInput; 515 var op = ev && ev.item && ev.item.getData(ZmOperation.KEY_ID); 516 var type = this._opToField[op]; 517 var targetInput = this._addrInputField[type]; 518 if (sourceInput && targetInput) { 519 var sel = sourceInput.getSelection(); 520 if (sel.length) { 521 for (var i = 0; i < sel.length; i++) { 522 var bubble = sel[i]; 523 this._showAddressField(type, true); 524 targetInput.addBubble({bubble:bubble}); 525 sourceInput.removeBubble(bubble.id); 526 } 527 } 528 } 529 }; 530 531 ZmRecipients.prototype._setAddrFieldValue = 532 function(type, value) { 533 534 var addrInput = this._addrInputField[type]; 535 if (addrInput) { 536 addrInput.setValue(value, true); 537 } 538 }; 539 540 // Generic routine for attaching an event handler to a field. Since "this" for the handlers is 541 // the incoming event, we need a way to get at ZmComposeView, so it's added to the event target. 542 ZmRecipients.prototype._setEventHandler = 543 function(id, event, addrType) { 544 var field = document.getElementById(id); 545 field._recipients = this; 546 if (addrType) { 547 field._addrType = addrType; 548 } 549 var lcEvent = event.toLowerCase(); 550 field[lcEvent] = ZmRecipients["_" + event]; 551 }; 552 553 // set focus within tab group to element so tabbing works 554 ZmRecipients._onFocus = 555 function(ev) { 556 557 ev = DwtUiEvent.getEvent(ev); 558 var element = DwtUiEvent.getTargetWithProp(ev, "id"); 559 if (!element) { return true; } 560 561 var kbMgr = appCtxt.getKeyboardMgr(); 562 if (kbMgr.__currTabGroup) { 563 kbMgr.__currTabGroup.setFocusMember(element); 564 } 565 }; 566 567 // Transfers addresses from the contact picker to the compose view. 568 ZmRecipients.prototype._contactPickerOkCallback = 569 function(addrs) { 570 571 if (this._enableContainerInputs) { 572 this._enableContainerInputs(true); 573 } 574 for (var i = 0; i < this._fieldNames.length; i++) { 575 var type = this._fieldNames[i]; 576 this.setAddress(type, ""); 577 // If there was only one button, the picker will just return the list of selections, 578 // not a list per button type 579 var typeAddrs = (this._fieldNames.length == 1) ? addrs : addrs[type]; 580 var addrVec = ZmRecipients.expandAddrs(typeAddrs); 581 this.addAddresses(type, addrVec); 582 } 583 584 // Still need this here since REMOVING stuff with the picker does not call removeBubble in the ZmAddressInputField. 585 // Also - it's better to do it once than for every bubble in this case. user might add many addresses with the picker 586 this._bubblesChangedCallback(); 587 588 if (this._contactPopdownListener) { 589 this._contactPicker.removePopdownListener(this._contactPopdownListener); 590 } 591 this._contactPicker.popdown(); 592 if (this._reenter) { 593 this._reenter(); 594 } 595 }; 596 597 // Expands any addresses that are groups 598 ZmRecipients.expandAddrs = 599 function(addrs) { 600 var addrsNew = []; 601 var addrsArray = (addrs instanceof AjxVector) ? addrs.getArray() : addrs; 602 if (addrsArray && addrsArray.length) { 603 for (var i = 0; i < addrsArray.length; i++) { 604 var addr = addrsArray[i]; 605 if (addr) { 606 if (addr.isGroup && !(addr.__contact && addr.__contact.isDL)) { 607 var members = addr.__contact ? addr.__contact.getGroupMembers().good.getArray() : 608 AjxEmailAddress.split(addr.address); 609 addrsNew = addrsNew.concat(members); 610 } 611 else { 612 addrsNew.push(addr); 613 } 614 } 615 } 616 } 617 return AjxVector.fromArray(addrsNew); 618 }; 619 620 ZmRecipients.prototype._contactPickerCancelCallback = 621 function() { 622 if (this._enableContainerInputs) { 623 this._enableContainerInputs(true); 624 } 625 if (this._reenter) { 626 this._reenter(); 627 } 628 }; 629 630 ZmRecipients.prototype._toggleBccField = 631 function(show) { 632 var visible = AjxUtil.isBoolean(show) ? show : !Dwt.getVisible(this._divEl[AjxEmailAddress.BCC]); 633 this._showAddressField(AjxEmailAddress.BCC, visible); 634 }; 635