Skip to main content

max / goingson

5.9 KB · 164 lines History Blame Raw
1 /**
2 * GoingsOn - Email Address Autocomplete
3 * Typeahead for To/CC/BCC fields using contacts database.
4 */
5
6 (function() {
7 'use strict';
8 const esc = GoingsOn.utils.escapeHtml;
9
10 let contactEmails = null; // [{name, email}], lazy-loaded
11
12 /**
13 * Load all contact email addresses for autocomplete.
14 * Cached after first load; call refresh() to reload.
15 */
16 async function ensureLoaded() {
17 if (contactEmails !== null) return;
18 try {
19 const contacts = await GoingsOn.api.contacts.listFiltered(null, null, true);
20 contactEmails = [];
21 for (const c of contacts) {
22 const name = c.displayName || c.display_name || '';
23 const isImplicit = c.isImplicit || false;
24 if (c.emails && c.emails.length > 0) {
25 for (const e of c.emails) {
26 contactEmails.push({ name, email: e.address, isImplicit });
27 }
28 }
29 }
30 } catch (_) {
31 contactEmails = [];
32 }
33 }
34
35 function refresh() {
36 contactEmails = null;
37 }
38
39 /**
40 * Get the current token being typed (after the last comma).
41 */
42 function getLastToken(input) {
43 const val = input.value;
44 const cursor = input.selectionStart || val.length;
45 const before = val.slice(0, cursor);
46 const lastComma = before.lastIndexOf(',');
47 return { token: before.slice(lastComma + 1).trim(), lastComma };
48 }
49
50 function filterContacts(token) {
51 if (!token || token.length < 1 || !contactEmails) return [];
52 const q = token.toLowerCase();
53 return contactEmails
54 .filter(c => c.email.toLowerCase().includes(q) || c.name.toLowerCase().includes(q))
55 .sort((a, b) => {
56 // Explicit contacts first, then implicit
57 if (a.isImplicit !== b.isImplicit) return a.isImplicit ? 1 : -1;
58 // Within tier, prefer prefix match on email
59 const aPrefix = a.email.toLowerCase().startsWith(q) ? 0 : 1;
60 const bPrefix = b.email.toLowerCase().startsWith(q) ? 0 : 1;
61 return aPrefix - bPrefix;
62 })
63 .slice(0, 8);
64 }
65
66 /**
67 * Attach autocomplete behavior to an email address input.
68 * Supports comma-separated addresses — autocompletes the current token.
69 * @param {HTMLInputElement} input - The text input element
70 */
71 function attach(input) {
72 let dropdown = null;
73 let activeIndex = -1;
74 let matches = [];
75
76 function show(filteredMatches) {
77 hide();
78 if (filteredMatches.length === 0) return;
79 matches = filteredMatches;
80 activeIndex = -1;
81
82 dropdown = document.createElement('div');
83 dropdown.className = 'autocomplete-dropdown';
84
85 filteredMatches.forEach((m, i) => {
86 const item = document.createElement('div');
87 item.className = 'autocomplete-item';
88 item.innerHTML = `<span class="autocomplete-name">${esc(m.name)}</span> <span class="autocomplete-email">${esc(m.email)}</span>`;
89 item.addEventListener('mousedown', (e) => {
90 e.preventDefault();
91 select(m.email);
92 });
93 dropdown.appendChild(item);
94 });
95
96 // Position relative to input's parent
97 const wrapper = input.parentElement;
98 wrapper.style.position = 'relative';
99 dropdown.style.position = 'absolute';
100 dropdown.style.top = input.offsetTop + input.offsetHeight + 'px';
101 dropdown.style.left = '0';
102 dropdown.style.right = '0';
103 wrapper.appendChild(dropdown);
104 }
105
106 function hide() {
107 if (dropdown) {
108 dropdown.remove();
109 dropdown = null;
110 activeIndex = -1;
111 matches = [];
112 }
113 }
114
115 function select(email) {
116 const val = input.value;
117 const cursor = input.selectionStart || val.length;
118 const before = val.slice(0, cursor);
119 const after = val.slice(cursor);
120 const lastComma = before.lastIndexOf(',');
121 const prefix = lastComma >= 0 ? before.slice(0, lastComma + 1) + ' ' : '';
122 input.value = prefix + email + ', ' + after.trimStart();
123 const newCursor = (prefix + email + ', ').length;
124 input.setSelectionRange(newCursor, newCursor);
125 input.focus();
126 hide();
127 }
128
129 input.addEventListener('input', async () => {
130 await ensureLoaded();
131 const { token } = getLastToken(input);
132 show(filterContacts(token));
133 });
134
135 input.addEventListener('blur', () => {
136 setTimeout(hide, 150);
137 });
138
139 input.addEventListener('keydown', (e) => {
140 if (!dropdown) return;
141 const items = dropdown.querySelectorAll('.autocomplete-item');
142
143 if (e.key === 'ArrowDown') {
144 e.preventDefault();
145 activeIndex = Math.min(activeIndex + 1, items.length - 1);
146 items.forEach((el, i) => el.classList.toggle('active', i === activeIndex));
147 } else if (e.key === 'ArrowUp') {
148 e.preventDefault();
149 activeIndex = Math.max(activeIndex - 1, 0);
150 items.forEach((el, i) => el.classList.toggle('active', i === activeIndex));
151 } else if (e.key === 'Enter' || e.key === 'Tab') {
152 if (activeIndex >= 0 && activeIndex < matches.length) {
153 e.preventDefault();
154 select(matches[activeIndex].email);
155 }
156 } else if (e.key === 'Escape') {
157 hide();
158 }
159 });
160 }
161
162 GoingsOn.autocomplete = { attach, refresh, getContacts: () => contactEmails || [] };
163 })();
164