summaryrefslogtreecommitdiffstats
path: root/comm/mailnews/addrbook/content/abMailListDialog.js
blob: 6d512d3014d36f7bc49bc83a5eb1e4223fd08184 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/* import-globals-from ../../../mail/components/addrbook/content/abCommon.js */
/* import-globals-from ../../../mail/components/compose/content/addressingWidgetOverlay.js */

var { MailServices } = ChromeUtils.import(
  "resource:///modules/MailServices.jsm"
);
var { AppConstants } = ChromeUtils.importESModule(
  "resource://gre/modules/AppConstants.sys.mjs"
);

top.MAX_RECIPIENTS = 1;

var gListCard;
var gEditList;
var gOldListName = "";

var gAWContentHeight = 0;
var gAWRowHeight = 0;
var gNumberOfCols = 0;

var test_addresses_sequence = false;

if (
  Services.prefs.getPrefType("mail.debug.test_addresses_sequence") ==
  Ci.nsIPrefBranch.PREF_BOOL
) {
  test_addresses_sequence = Services.prefs.getBoolPref(
    "mail.debug.test_addresses_sequence"
  );
}

try {
  var gDragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
    Ci.nsIDragService
  );
} catch (e) {}

// Returns the load context for the current window
function getLoadContext() {
  return window.docShell.QueryInterface(Ci.nsILoadContext);
}

function mailingListExists(listname) {
  if (MailServices.ab.mailListNameExists(listname)) {
    let bundle = Services.strings.createBundle(
      "chrome://messenger/locale/addressbook/addressBook.properties"
    );
    Services.prompt.alert(
      window,
      bundle.GetStringFromName("mailListNameExistsTitle"),
      bundle.GetStringFromName("mailListNameExistsMessage")
    );
    return true;
  }
  return false;
}

/**
 * Get the new inputs from the create/edit mailing list dialog and use them to
 * update the mailing list that was passed in as an argument.
 *
 * @param {nsIAbDirectory} mailList - The mailing list object to update. When
 *   creating a new list it will be newly created and empty.
 * @param {boolean} isNewList - Whether we are populating a new list.
 * @returns {boolean} - Whether the operation succeeded or not.
 */
function updateMailList(mailList, isNewList) {
  let bundle = Services.strings.createBundle(
    "chrome://messenger/locale/addressbook/addressBook.properties"
  );
  let listname = document.getElementById("ListName").value.trim();

  if (listname.length == 0) {
    alert(bundle.GetStringFromName("emptyListName"));
    return false;
  }

  if (listname.match("  ")) {
    alert(bundle.GetStringFromName("badListNameSpaces"));
    return false;
  }

  for (let char of ',;"<>') {
    if (listname.includes(char)) {
      alert(bundle.GetStringFromName("badListNameCharacters"));
      return false;
    }
  }

  let canonicalNewListName = listname.toLowerCase();
  let canonicalOldListName = gOldListName.toLowerCase();
  if (isNewList || canonicalOldListName != canonicalNewListName) {
    if (mailingListExists(listname)) {
      // After showing the "Mailing List Already Exists" error alert,
      // focus ListName input field for user to choose a different name.
      document.getElementById("ListName").focus();
      return false;
    }
  }

  mailList.isMailList = true;
  mailList.dirName = listname;
  mailList.listNickName = document.getElementById("ListNickName").value;
  mailList.description = document.getElementById("ListDescription").value;

  return true;
}

/**
 * Updates the members of the mailing list.
 *
 * @param {nsIAbDirectory} mailList - The mailing list object to
 *   update. When creating a new list it will be newly created and empty.
 * @param {nsIAbDirectory} parentDirectory - The address book containing the
 *   mailing list.
 */
function updateMailListMembers(mailList, parentDirectory) {
  // Gather email address inputs into a single string (comma-separated).
  let addresses = Array.from(
    document.querySelectorAll(".textbox-addressingWidget"),
    element => element.value
  )
    .filter(value => value.trim())
    .join();

  // Convert the addresses string into address objects.
  let addressObjects =
    MailServices.headerParser.makeFromDisplayAddress(addresses);
  let existingCards = mailList.childCards;

  // Work out which addresses need to be added...
  let existingCardAddresses = existingCards.map(card => card.primaryEmail);
  let addressObjectsToAdd = addressObjects.filter(
    aObj => !existingCardAddresses.includes(aObj.email)
  );

  // ... and which need to be removed.
  let addressObjectAddresses = addressObjects.map(aObj => aObj.email);
  let cardsToRemove = existingCards.filter(
    card => !addressObjectAddresses.includes(card.primaryEmail)
  );

  for (let { email, name } of addressObjectsToAdd) {
    let card = parentDirectory.cardForEmailAddress(email);
    if (!card) {
      card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
        Ci.nsIAbCard
      );
      card.primaryEmail = email;
      card.displayName = name || email;
    }
    mailList.addCard(card);
  }

  if (cardsToRemove.length > 0) {
    mailList.deleteCards(cardsToRemove);
  }
}

function MailListOKButton(event) {
  var popup = document.getElementById("abPopup");
  if (popup) {
    var uri = popup.getAttribute("value");

    // FIX ME - hack to avoid crashing if no ab selected because of blank option bug from template
    // should be able to just remove this if we are not seeing blank lines in the ab popup
    if (!uri) {
      event.preventDefault();
      return; // don't close window
    }
    // -----

    // Add mailing list to database
    var mailList =
      Cc["@mozilla.org/addressbook/directoryproperty;1"].createInstance();
    mailList = mailList.QueryInterface(Ci.nsIAbDirectory);

    if (updateMailList(mailList, true)) {
      var parentDirectory = GetDirectoryFromURI(uri);
      mailList = parentDirectory.addMailList(mailList);
      updateMailListMembers(mailList, parentDirectory);
      window.arguments[0].newListUID = mailList.UID;
      window.arguments[0].newListURI = mailList.URI;
    } else {
      event.preventDefault();
    }
  }
}

function OnLoadNewMailList() {
  var selectedAB = null;

  if ("arguments" in window && window.arguments[0]) {
    var abURI = window.arguments[0].selectedAB;
    if (abURI && abURI != kAllDirectoryRoot + "?") {
      var directory = GetDirectoryFromURI(abURI);
      if (directory.isMailList) {
        var parentURI = GetParentDirectoryFromMailingListURI(abURI);
        if (parentURI) {
          selectedAB = parentURI;
        }
      } else if (directory.readOnly) {
        selectedAB = kPersonalAddressbookURI;
      } else {
        selectedAB = abURI;
      }
    }

    let cards = window.arguments[0].cards;
    if (cards && cards.length > 0) {
      let listbox = document.getElementById("addressingWidget");
      let newListBoxNode = listbox.cloneNode(false);
      let templateNode = listbox.querySelector("richlistitem");

      top.MAX_RECIPIENTS = 0;
      for (let card of cards) {
        let address = MailServices.headerParser
          .makeMailboxObject(card.displayName, card.primaryEmail)
          .toString();
        SetInputValue(address, newListBoxNode, templateNode);
      }
      listbox.parentNode.replaceChild(newListBoxNode, listbox);
    }
  }

  if (!selectedAB) {
    selectedAB = kPersonalAddressbookURI;
  }

  // set popup with address book names
  var abPopup = document.getElementById("abPopup");
  abPopup.value = selectedAB;

  AppendNewRowAndSetFocus();
  awFitDummyRows(1);

  if (AppConstants.MOZ_APP_NAME == "seamonkey") {
    /* global awDocumentKeyPress */
    document.addEventListener("keypress", awDocumentKeyPress, true);
  }

  // focus on first name
  var listName = document.getElementById("ListName");
  if (listName) {
    setTimeout(
      function (firstTextBox) {
        firstTextBox.focus();
      },
      0,
      listName
    );
  }

  let input = document.getElementById("addressCol1#1");
  input.popup.addEventListener("click", () => {
    awReturnHit(input);
  });

  document.addEventListener("dialogaccept", MailListOKButton);
}

function EditListOKButton(event) {
  // edit mailing list in database
  if (updateMailList(gEditList, false)) {
    let parentURI = GetParentDirectoryFromMailingListURI(gEditList.URI);
    let parentDirectory = GetDirectoryFromURI(parentURI);
    updateMailListMembers(gEditList, parentDirectory);
    if (gListCard) {
      // modify the list card (for the results pane) from the mailing list
      gListCard.displayName = gEditList.dirName;
      gListCard.lastName = gEditList.dirName;
      gListCard.setProperty("NickName", gEditList.listNickName);
      gListCard.setProperty("Notes", gEditList.description);
    }

    gEditList.editMailListToDatabase(gListCard);

    window.arguments[0].refresh = true;
    return; // close the window
  }
  event.preventDefault();
}

function OnLoadEditList() {
  gListCard = window.arguments[0].abCard;
  var listUri = window.arguments[0].listURI;

  gEditList = GetDirectoryFromURI(listUri);

  document.getElementById("ListName").value = gEditList.dirName;
  document.getElementById("ListNickName").value = gEditList.listNickName;
  document.getElementById("ListDescription").value = gEditList.description;
  gOldListName = gEditList.dirName;

  let bundle = Services.strings.createBundle(
    "chrome://messenger/locale/addressbook/addressBook.properties"
  );
  document.title = bundle.formatStringFromName("mailingListTitleEdit", [
    gOldListName,
  ]);

  let cards = gEditList.childCards;
  if (cards.length > 0) {
    let listbox = document.getElementById("addressingWidget");
    let newListBoxNode = listbox.cloneNode(false);
    let templateNode = listbox.querySelector("richlistitem");

    top.MAX_RECIPIENTS = 0;
    for (let card of cards) {
      let address = MailServices.headerParser
        .makeMailboxObject(card.displayName, card.primaryEmail)
        .toString();
      SetInputValue(address, newListBoxNode, templateNode);
    }
    listbox.parentNode.replaceChild(newListBoxNode, listbox);
  }

  // Is this directory read-only? If so, we now need to set all the fields to
  // read-only.
  if (gEditList.readOnly) {
    const kMailListFields = ["ListName", "ListNickName", "ListDescription"];

    for (let i = 0; i < kMailListFields.length; ++i) {
      document.getElementById(kMailListFields[i]).readOnly = true;
    }

    document.querySelector("dialog").buttons = "accept";

    // Getting a sane read-only implementation for the addressing widget would
    // basically need a separate dialog. Given I'm not sure about the future of
    // the mailing list dialog in its current state, let's just disable it
    // completely.
    document.getElementById("addressingWidget").disabled = true;
  } else {
    document.addEventListener("dialogaccept", EditListOKButton);
  }

  if (AppConstants.MOZ_APP_NAME == "seamonkey") {
    document.addEventListener("keypress", awDocumentKeyPress, true);
  }

  // workaround for bug 118337 - for mailing lists that have more rows than fits inside
  // the display, the value of the textbox inside the new row isn't inherited into the input -
  // the first row then appears to be duplicated at the end although it is actually empty.
  // see awAppendNewRow which copies first row and clears it
  setTimeout(AppendLastRow, 0);

  document.querySelectorAll(`input[is="autocomplete-input"]`).forEach(input => {
    input.popup.addEventListener("click", () => {
      awReturnHit(input);
    });
  });
}

function AppendLastRow() {
  AppendNewRowAndSetFocus();
  awFitDummyRows(1);

  // focus on first name
  let listName = document.getElementById("ListName");
  if (listName) {
    listName.focus();
  }
}

function AppendNewRowAndSetFocus() {
  let lastInput = awGetInputElement(top.MAX_RECIPIENTS);
  if (lastInput && lastInput.value) {
    awAppendNewRow(true);
  } else {
    awSetFocusTo(lastInput);
  }
}

function SetInputValue(inputValue, parentNode, templateNode) {
  top.MAX_RECIPIENTS++;

  var newNode = templateNode.cloneNode(true);
  parentNode.appendChild(newNode); // we need to insert the new node before we set the value of the select element!

  var input = newNode.querySelector(`input[is="autocomplete-input"]`);
  let label = newNode.querySelector(`label.person-icon`);
  if (input) {
    input.value = inputValue;
    input.setAttribute("id", "addressCol1#" + top.MAX_RECIPIENTS);
    label.setAttribute("for", "addressCol1#" + top.MAX_RECIPIENTS);
    input.popup.addEventListener("click", () => {
      awReturnHit(input);
    });
  }
}

function awClickEmptySpace(target, setFocus) {
  if (target == null || target.localName != "hbox") {
    return;
  }

  let lastInput = awGetInputElement(top.MAX_RECIPIENTS);

  if (lastInput && lastInput.value) {
    awAppendNewRow(setFocus);
  } else if (setFocus) {
    awSetFocusTo(lastInput);
  }
}

function awReturnHit(inputElement) {
  let row = awGetRowByInputElement(inputElement);
  if (inputElement.value) {
    let nextInput = awGetInputElement(row + 1);
    if (!nextInput) {
      awAppendNewRow(true);
    } else {
      awSetFocusTo(nextInput);
    }
  }
}

function awDeleteRow(rowToDelete) {
  /* When we delete a row, we must reset the id of others row in order to not break the sequence */
  var maxRecipients = top.MAX_RECIPIENTS;
  awRemoveRow(rowToDelete);

  var numberOfCols = awGetNumberOfCols();
  for (var row = rowToDelete + 1; row <= maxRecipients; row++) {
    for (var col = 1; col <= numberOfCols; col++) {
      awGetElementByCol(row, col).setAttribute(
        "id",
        "addressCol" + col + "#" + (row - 1)
      );
    }
  }

  awTestRowSequence();
}

/**
 * Append a new row.
 *
 * @param {boolean} setFocus - Whether to set the focus on the new row.
 * @returns {Element?} The input element from the new row.
 */
function awAppendNewRow(setFocus) {
  let body = document.getElementById("addressingWidget");
  let listitem1 = awGetListItem(1);
  let input;
  let label;

  if (body && listitem1) {
    let nextDummy = awGetNextDummyRow();
    let newNode = listitem1.cloneNode(true);
    if (nextDummy) {
      body.replaceChild(newNode, nextDummy);
    } else {
      body.appendChild(newNode);
    }

    top.MAX_RECIPIENTS++;

    input = newNode.querySelector(`input[is="autocomplete-input"]`);
    label = newNode.querySelector(`label.person-icon`);
    if (input) {
      input.value = "";
      input.setAttribute("id", "addressCol1#" + top.MAX_RECIPIENTS);
      label.setAttribute("for", "addressCol1#" + top.MAX_RECIPIENTS);
      input.popup.addEventListener("click", () => {
        awReturnHit(input);
      });
    }
    // Focus the new input widget.
    if (setFocus && input) {
      awSetFocusTo(input);
    }
  }
  return input;
}

// functions for accessing the elements in the addressing widget

/**
 * Returns the recipient inputbox for a row.
 *
 * @param {integer} row - Index of the recipient row to return. Starts at 1.
 * @returns {Element} This returns the input element.
 */
function awGetInputElement(row) {
  return document.getElementById("addressCol1#" + row);
}

function awGetElementByCol(row, col) {
  var colID = "addressCol" + col + "#" + row;
  return document.getElementById(colID);
}

function awGetListItem(row) {
  var listbox = document.getElementById("addressingWidget");
  if (listbox && row > 0) {
    return listbox.getItemAtIndex(row - 1);
  }

  return null;
}

/**
 * @param {Element} inputElement - The recipient input element.
 * @returns {integer} The row index (starting from 1) where the input element
 *   is found. 0 if the element is not found.
 */
function awGetRowByInputElement(inputElement) {
  if (!inputElement) {
    return 0;
  }

  var listitem = inputElement.parentNode.parentNode;
  return (
    document.getElementById("addressingWidget").getIndexOfItem(listitem) + 1
  );
}

function DragOverAddressListTree(event) {
  var dragSession = gDragService.getCurrentSession();

  // XXX add support for other flavors here
  if (dragSession.isDataFlavorSupported("text/x-moz-address")) {
    dragSession.canDrop = true;
  }
}

function DropOnAddressListTree(event) {
  let dragSession = gDragService.getCurrentSession();
  let trans;

  try {
    trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(
      Ci.nsITransferable
    );
    trans.init(getLoadContext());
    trans.addDataFlavor("text/x-moz-address");
  } catch (ex) {
    return;
  }

  for (let i = 0; i < dragSession.numDropItems; ++i) {
    dragSession.getData(trans, i);
    let dataObj = {};
    let bestFlavor = {};
    trans.getAnyTransferData(bestFlavor, dataObj);
    if (dataObj) {
      dataObj = dataObj.value.QueryInterface(Ci.nsISupportsString);
    }
    if (!dataObj) {
      continue;
    }

    // pull the URL out of the data object
    let address = dataObj.data.substring(0, dataObj.length);
    if (!address) {
      continue;
    }

    DropListAddress(event.target, address);
  }
}

function DropListAddress(target, address) {
  // Set focus on a new available, visible row.
  awClickEmptySpace(target, true);
  if (top.MAX_RECIPIENTS == 0) {
    top.MAX_RECIPIENTS = 1;
  }

  // Break apart the MIME-ready header address into individual addressees to
  // add to the dialog.
  let addresses = MailServices.headerParser.parseEncodedHeader(address);
  for (let addr of addresses) {
    let lastInput = awGetInputElement(top.MAX_RECIPIENTS);
    lastInput.value = addr.toString();
    awAppendNewRow(true);
  }
}

/**
 * Handles keypress events for the email address inputs (that auto-fill)
 * in the Address Book Mailing List dialogs. When a comma-separated list of
 * addresses is entered on one row, split them into one address per row. Only
 * add a new blank row on "Enter" key. On "Tab" key focus moves to the "Cancel"
 * button.
 *
 * @param {KeyboardEvent} event - The DOM keypress event.
 * @param {Element} element - The element that triggered the keypress event.
 */
function awAbRecipientKeyPress(event, element) {
  if (event.key != "Enter" && event.key != "Tab") {
    return;
  }

  if (!element.value) {
    if (event.key == "Enter") {
      awReturnHit(element);
    }
  } else {
    let inputElement = element;
    let originalRow = awGetRowByInputElement(element);
    let row;
    let addresses = MailServices.headerParser.makeFromDisplayAddress(
      element.value
    );

    if (addresses.length > 1) {
      // Collect any existing addresses from the following rows so we don't
      // simply overwrite them.
      row = originalRow + 1;
      inputElement = awGetInputElement(row);

      while (inputElement) {
        if (inputElement.value) {
          addresses.push(inputElement.value);
          inputElement.value = "";
        }
        row += 1;
        inputElement = awGetInputElement(row);
      }
    }

    // Insert the addresses, adding new rows if needed.
    row = originalRow;
    let needNewRows = false;

    for (let address of addresses) {
      if (needNewRows) {
        inputElement = awAppendNewRow(false);
      } else {
        inputElement = awGetInputElement(row);
        if (!inputElement) {
          needNewRows = true;
          inputElement = awAppendNewRow(false);
        }
      }

      if (inputElement) {
        inputElement.value = address;
      }
      row += 1;
    }

    if (event.key == "Enter") {
      // Prevent the dialog from closing. "Enter" inserted a new row instead.
      event.preventDefault();
      awReturnHit(inputElement);
    } else if (event.key == "Tab") {
      // Focus the last row to let "Tab" move focus to the "Cancel" button.
      let lastRow = row - 1;
      awGetInputElement(lastRow).focus();
    }
  }
}

/**
 * Handle keydown event on a recipient input.
 * Enables recipient row deletion with DEL or BACKSPACE and
 * recipient list navigation with cursor up/down.
 *
 * Note that the keydown event fires for ALL keys, so this may affect
 * autocomplete as user enters a recipient text.
 *
 * @param {KeyboardEvent} event - The keydown event fired on a recipient input.
 * @param {HTMLInputElement} inputElement - The recipient input element
 *   on which the event fired (textbox-addressingWidget).
 */
function awRecipientKeyDown(event, inputElement) {
  switch (event.key) {
    // Enable deletion of empty recipient rows.
    case "Delete":
    case "Backspace":
      if (inputElement.value.length == 1 && event.repeat) {
        // User is holding down Delete or Backspace to delete recipient text
        // inline and is now deleting the last character: Set flag to
        // temporarily block row deletion.
        top.awRecipientInlineDelete = true;
      }
      if (!inputElement.value && !event.altKey) {
        // When user presses DEL or BACKSPACE on an empty row, and it's not an
        // ongoing inline deletion, and not ALT+BACKSPACE for input undo,
        // we delete the row.
        if (top.awRecipientInlineDelete && !event.repeat) {
          // User has released and re-pressed Delete or Backspace key
          // after holding them down to delete recipient text inline:
          // unblock row deletion.
          top.awRecipientInlineDelete = false;
        }
        if (!top.awRecipientInlineDelete) {
          let deleteForward = event.key == "Delete";
          awDeleteHit(inputElement, deleteForward);
        }
      }
      break;

    // Enable browsing the list of recipients up and down with cursor keys.
    case "ArrowDown":
    case "ArrowUp":
      // Only browse recipients if the autocomplete popup is not open.
      if (!inputElement.popupOpen) {
        let row = awGetRowByInputElement(inputElement);
        let down = event.key == "ArrowDown";
        let noEdgeRow = down ? row < top.MAX_RECIPIENTS : row > 1;
        if (noEdgeRow) {
          let targetRow = down ? row + 1 : row - 1;
          awSetFocusTo(awGetInputElement(targetRow));
        }
      }
      break;
  }
}

/**
 * Delete recipient row (addressingWidgetItem) from UI.
 *
 * @param {HTMLInputElement} inputElement - The recipient input element.
 *   textbox-addressingWidget) whose parent row (addressingWidgetItem) will be
 *   deleted.
 * @param {boolean} deleteForward - true: focus next row after deleting the row
 *   false: focus previous row after deleting the row
 */
function awDeleteHit(inputElement, deleteForward = false) {
  let row = awGetRowByInputElement(inputElement);

  // Don't delete the row if it's the last one remaining; just reset it.
  if (top.MAX_RECIPIENTS <= 1) {
    inputElement.value = "";
    return;
  }

  // Set the focus to the input field of the next/previous row according to
  // the direction of deleting if possible.
  // Note: awSetFocusTo() is asynchronous, i.e. we'll focus after row removal.
  if (
    (!deleteForward && row > 1) ||
    (deleteForward && row == top.MAX_RECIPIENTS)
  ) {
    // We're deleting backwards, but not the first row,
    // or forwards on the last row: Focus previous row.
    awSetFocusTo(awGetInputElement(row - 1));
  } else {
    // We're deleting forwards, but not the last row,
    // or backwards on the first row: Focus next row.
    awSetFocusTo(awGetInputElement(row + 1));
  }

  // Delete the row.
  awDeleteRow(row);
}

function awTestRowSequence() {
  /*
    This function is for debug and testing purpose only, normal user should not run it!

    Every time we insert or delete a row, we must be sure we didn't break the ID sequence of
    the addressing widget rows. This function will run a quick test to see if the sequence still ok

    You need to define the pref mail.debug.test_addresses_sequence to true in order to activate it
  */

  if (!test_addresses_sequence) {
    return true;
  }

  // Debug code to verify the sequence is still good.

  let listbox = document.getElementById("addressingWidget");
  let listitems = listbox.itemChildren;
  if (listitems.length >= top.MAX_RECIPIENTS) {
    for (let i = 1; i <= listitems.length; i++) {
      let item = listitems[i - 1];
      let inputID = item
        .querySelector(`input[is="autocomplete-input"]`)
        .id.split("#")[1];
      let menulist = item.querySelector("menulist");
      // In some places like the mailing list dialog there is no menulist,
      // and so no popupID that needs to be kept in sequence.
      let popupID = menulist && menulist.id.split("#")[1];
      if (inputID != i || (popupID && popupID != i)) {
        dump(
          `#ERROR: sequence broken at row ${i}, ` +
            `inputID=${inputID}, popupID=${popupID}\n`
        );
        return false;
      }
      dump("---SEQUENCE OK---\n");
      return true;
    }
  } else {
    dump(
      `#ERROR: listitems.length(${listitems.length}) < ` +
        `top.MAX_RECIPIENTS(${top.MAX_RECIPIENTS})\n`
    );
  }

  return false;
}

function awRemoveRow(row) {
  awGetListItem(row).remove();
  awFitDummyRows();

  top.MAX_RECIPIENTS--;
}

function awGetNumberOfCols() {
  if (gNumberOfCols == 0) {
    var listbox = document.getElementById("addressingWidget");
    var listCols = listbox.getElementsByTagName("treecol");
    gNumberOfCols = listCols.length;
    if (!gNumberOfCols) {
      // If no cols defined, that means we have only one!
      gNumberOfCols = 1;
    }
  }

  return gNumberOfCols;
}

function awCreateDummyItem(aParent) {
  var listbox = document.getElementById("addressingWidget");
  var item = listbox.getItemAtIndex(0);

  var titem = document.createXULElement("richlistitem");
  titem.setAttribute("_isDummyRow", "true");
  titem.setAttribute("class", "dummy-row");
  titem.style.height = item.getBoundingClientRect().height + "px";

  for (let i = 0; i < awGetNumberOfCols(); i++) {
    let cell = awCreateDummyCell(titem);
    if (item.children[i].hasAttribute("style")) {
      cell.setAttribute("style", item.children[i].getAttribute("style"));
    }
    if (item.children[i].hasAttribute("flex")) {
      cell.setAttribute("flex", item.children[i].getAttribute("flex"));
    }
  }

  if (aParent) {
    aParent.appendChild(titem);
  }

  return titem;
}

function awFitDummyRows() {
  awCalcContentHeight();
  awCreateOrRemoveDummyRows();
}

function awCreateOrRemoveDummyRows() {
  let listbox = document.getElementById("addressingWidget");
  let listboxHeight = listbox.getBoundingClientRect().height;

  // remove rows to remove scrollbar
  let kids = listbox.querySelectorAll("[_isDummyRow]");
  for (
    let i = kids.length - 1;
    gAWContentHeight > listboxHeight && i >= 0;
    --i
  ) {
    gAWContentHeight -= gAWRowHeight;
    kids[i].remove();
  }

  // add rows to fill space
  if (gAWRowHeight) {
    while (gAWContentHeight + gAWRowHeight < listboxHeight) {
      awCreateDummyItem(listbox);
      gAWContentHeight += gAWRowHeight;
    }
  }
}

function awCalcContentHeight() {
  var listbox = document.getElementById("addressingWidget");
  var items = listbox.itemChildren;

  gAWContentHeight = 0;
  if (items.length > 0) {
    // all rows are forced to a uniform height in xul listboxes, so
    // find the first listitem with a boxObject and use it as precedent
    var i = 0;
    do {
      gAWRowHeight = items[i].getBoundingClientRect().height;
      ++i;
    } while (i < items.length && !gAWRowHeight);
    gAWContentHeight = gAWRowHeight * items.length;
  }
}

/* ::::::::::: addressing widget dummy rows ::::::::::::::::: */

function awCreateDummyCell(aParent) {
  var cell = document.createXULElement("hbox");
  cell.setAttribute("class", "addressingWidgetCell dummy-row-cell");
  if (aParent) {
    aParent.appendChild(cell);
  }

  return cell;
}

function awGetNextDummyRow() {
  // gets the next row from the top down
  return document.querySelector("#addressingWidget > [_isDummyRow]");
}

/**
 * Set focus to the specified element, typically a recipient input element.
 * We do this asynchronously to allow other processes like adding or removing rows
 * to complete before shifting focus.
 *
 * @param {Element} element - The element to receive focus asynchronously.
 */
function awSetFocusTo(element) {
  // Remember the (input) element to focus for asynchronous focusing, so that we
  // play safe if this gets called again and the original element gets removed
  // before we can focus it.
  top.awInputToFocus = element;
  setTimeout(_awSetFocusTo, 0);
}

function _awSetFocusTo() {
  top.awInputToFocus.focus();
}

// returns null if abURI is not a mailing list URI
function GetParentDirectoryFromMailingListURI(abURI) {
  var abURIArr = abURI.split("/");
  /*
   Turn "jsaddrbook://abook.sqlite/MailList6"
   into ["jsaddrbook:","","abook.sqlite","MailList6"],
   then into "jsaddrbook://abook.sqlite".

   Turn "moz-aboutlookdirectory:///<top dir ID>/<ML dir ID>"
   into ["moz-aboutlookdirectory:","","","<top dir ID>","<ML dir ID>"],
   and then into: "moz-aboutlookdirectory:///<top dir ID>".
  */
  if (
    abURIArr.length == 4 &&
    ["jsaddrbook:", "moz-abmdbdirectory:"].includes(abURIArr[0]) &&
    abURIArr[3] != ""
  ) {
    return abURIArr[0] + "//" + abURIArr[2];
  } else if (
    abURIArr.length == 5 &&
    abURIArr[0] == "moz-aboutlookdirectory:" &&
    abURIArr[4] != ""
  ) {
    return abURIArr[0] + "///" + abURIArr[3];
  }

  return null;
}