'use strict'; // ---- Contacts: your address book. Mail suggests these as you type a recipient, a message can be saved as a contact, birthdays show up in the calendar, // and a .vcf file (iCloud, Google or a phone export) can be imported or exported. const ctApi = (p, opts) => jfetch('/api/contacts' + p, opts); const ctOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined }); let ctList = null, ctAt = 0, ctQuery = ''; const ctLoad = async force => { if (!force && ctList && Date.now() - ctAt < 60000) return ctList; ctList = await ctApi('/'); ctAt = Date.now(); return ctList; }; const ctInit = c => (c.name.match(/\p{L}/gu) || ['?']).slice(0, 2).join('').toUpperCase(); const ctHue = s => { let h = 0; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) % 360; return h; }; const ctAv = c => `${esc(ctInit(c))}`; const ctBday = b => { if (!b) return ''; const noYear = b.startsWith('--'), [y, m, d] = noYear ? [0, b.slice(2, 4), b.slice(5, 7)] : [b.slice(0, 4), b.slice(5, 7), b.slice(8, 10)]; return new Date(2000, +m - 1, +d).toLocaleDateString([], { month: 'long', day: 'numeric' }) + (noYear ? '' : ', ' + y); }; const ctFind = (l, address) => { const a = String(address || '').toLowerCase(); return a ? l.find(c => c.emails.some(e => e.value === a)) : null; }; async function viewContacts(args = []) { const v = $('#view'); v.innerHTML = '

Contacts

Loading…

'; try { const l = await ctLoad(true); if (!location.hash.startsWith('#/contacts')) return; v.innerHTML = `

Contacts

`; const draw = () => { const q = ctQuery.toLowerCase(), rows = l.filter(c => !q || [c.name, c.org, c.notes, ...c.emails.map(e => e.value), ...c.phones.map(p => p.value)].some(x => String(x).toLowerCase().includes(q))); let last = ''; $('#ctl').innerHTML = rows.length ? rows.map(c => { const g = (ctInit(c)[0] || '#'), head = g !== last ? `
${esc(last = g)}
` : ''; return head + `${ctAv(c)}${esc(c.name)}${esc(c.org || (c.emails[0] || {}).value || (c.phones[0] || {}).value || '')}`; }).join('') : `

${l.length ? 'No one matches.' : 'No contacts yet. Add one, or import a .vcf file from iCloud, Google or your phone.'}

`; }; draw(); $('#ctq').oninput = e => { ctQuery = e.target.value.trim(); draw(); }; $('#ctnew').onclick = () => ctEdit(null); $('#ctmore').onclick = ctMore; if (args[0] === 'c' && args[1]) { const c = l.find(x => x.id === args[1]); if (c) ctShow(c); } } catch (e) { if (!location.hash.startsWith('#/contacts')) return; v.innerHTML = `

Contacts

${esc(e.message)}

`; $('#ctretry').onclick = () => route(); } } function ctMore() { const el = openSheet('Contacts', '', `
Export all as .vcfA backup you can open in any contacts app
`); el.querySelector('#ctfile').onchange = async e => { const f = e.target.files[0]; if (!f) return; try { if (f.size > 7e6) throw new Error('That file is too large'); const r = await ctApi('/import', ctOpt('POST', { vcf: await f.text() })); closeSheet(); toast(`Added ${r.added}, updated ${r.merged}${r.skipped ? ', skipped ' + r.skipped : ''}`); route(); } catch (err) { toast(err.message); } }; } function ctShow(c) { const mailOn = allowed('mail') && cfg.mail; const line = (label, html) => html ? `
${esc(label)}${html}
` : ''; const el = openSheet(c.name, c.org || '', `
${c.emails.map(e => line(e.label, mailOn ? `${esc(e.value)}` : esc(e.value))).join('')} ${c.phones.map(p => line(p.label, `${esc(p.value)}`)).join('')} ${line('address', c.address ? `${esc(c.address)}` : '')} ${line('birthday', esc(ctBday(c.birthday)))}${line('notes', esc(c.notes).replace(/\n/g, '
'))}
${mailOn && c.emails[0] ? `` : ''}
`); el.querySelectorAll('[data-mail]').forEach(a => a.onclick = e => { e.preventDefault(); closeSheet(); ctWrite(a.dataset.mail); }); if (el.querySelector('#ctw')) el.querySelector('#ctw').onclick = () => { closeSheet(); ctWrite(c.emails[0].value); }; if (el.querySelector('#ctm')) el.querySelector('#ctm').onclick = () => { closeSheet(); mail.q = c.emails[0].value; location.hash = '#/mail'; if (location.hash === '#/mail' && typeof mailLoadList === 'function') mailLoadList(true); }; el.querySelector('#cte').onclick = () => { closeSheet(); ctEdit(c); }; el.querySelector('#ctd').onclick = () => { closeSheet(); confirmSheet('Delete this contact?', c.name, 'Delete', async () => { try { await ctApi('/delete', ctOpt('POST', { id: c.id })); toast('Deleted'); ctList = null; route(); } catch (e) { toast(e.message); } }); }; } /** A new message to this address. */ function ctWrite(address) { if (typeof mailCompose === 'function') mailCompose({ to: address }); } function ctEdit(c, seed) { const x = c || seed || { name: '', org: '', emails: [], phones: [], address: '', birthday: '', notes: '' }; const rows = (kind, items, ph) => `
${(items.length ? items : [{ label: '', value: '' }]).map(i => `
`).join('')}
Add another`; const el = openSheet(c ? 'Edit contact' : 'New contact', '', `
${rows('emails', x.emails, 'name@example.com')}${rows('phones', x.phones, '555 123 4567')}
`); el.querySelector('#ctx').onclick = closeSheet; el.querySelectorAll('[data-more]').forEach(a => a.onclick = e => { e.preventDefault(); const box = el.querySelector(`.ctrows[data-k="${a.dataset.more}"]`), first = box.firstElementChild; const n = first.cloneNode(true); n.querySelectorAll('input').forEach(i => { i.value = ''; }); box.appendChild(n); n.querySelector('[data-v]').focus(); }); const grab = k => [...el.querySelectorAll(`.ctrows[data-k="${k}"] .bar`)].map(r => ({ label: r.querySelector('[data-l]').value.trim().toLowerCase(), value: r.querySelector('[data-v]').value.trim() })).filter(i => i.value); el.querySelector('#ctf').onsubmit = async e => { e.preventDefault(); const err = el.querySelector('#cter'); err.textContent = ''; const keepYearless = x.birthday && x.birthday.startsWith('--') && !el.querySelector('#ctb').value; const body = { id: c && c.id, name: el.querySelector('#ctn').value, org: el.querySelector('#cto').value, emails: grab('emails'), phones: grab('phones'), address: el.querySelector('#cta').value, birthday: keepYearless ? x.birthday : el.querySelector('#ctb').value, notes: el.querySelector('#ctt').value }; try { const saved = await ctApi('/', ctOpt('POST', body)); ctList = null; closeSheet(); toast('Saved'); if (location.hash.startsWith('#/contacts')) route(); return saved; } catch (er) { err.textContent = er.message; } }; el.querySelector('#ctn').focus(); } // ---- Mail: suggestions while typing a recipient, and "Save contact" on a message you are reading /** Suggest matching contacts under a recipient box (a comma-separated list: the part being typed is what is matched). */ function ctSuggest(input) { if (!input || typeof allowed !== 'function' || !allowed('contacts')) return; let box, sel = -1, rows = []; const part = () => { const v = input.value, i = v.lastIndexOf(','); return { head: v.slice(0, i + 1), word: v.slice(i + 1).trim() }; }; const close = () => { if (box) { box.remove(); box = null; } sel = -1; }; const pick = r => { const { head } = part(); input.value = (head ? head.replace(/\s*$/, ' ') : '') + r.email + ', '; close(); input.focus(); }; const show = async () => { const { word } = part(), w = word.toLowerCase(); if (w.length < 1) return close(); let l; try { l = await ctLoad(); } catch { return; } rows = []; for (const c of l) for (const e of c.emails) if (c.name.toLowerCase().includes(w) || e.value.includes(w) || (c.org || '').toLowerCase().includes(w)) rows.push({ name: c.name, email: e.value }); rows = rows.slice(0, 6); if (!rows.length) return close(); if (!box) { box = document.createElement('div'); box.className = 'ctsug'; input.parentNode.style.position = input.parentNode.style.position || 'relative'; input.insertAdjacentElement('afterend', box); } box.innerHTML = rows.map((r, i) => ``).join(''); box.querySelectorAll('button').forEach(b => b.onmousedown = e => { e.preventDefault(); pick(rows[b.dataset.i]); }); }; input.addEventListener('input', show); input.addEventListener('blur', () => setTimeout(close, 120)); input.addEventListener('keydown', e => { if (!box) return; if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); sel = (sel + (e.key === 'ArrowDown' ? 1 : -1) + rows.length) % rows.length; box.querySelectorAll('button').forEach((b, i) => b.classList.toggle('on', i === sel)); } else if (e.key === 'Enter' && sel >= 0) { e.preventDefault(); pick(rows[sel]); } else if (e.key === 'Escape') { e.stopPropagation(); close(); } }); } /** On a message being read: save the sender, or open them if they are already a contact. */ async function ctMailHook(rd, m) { if (typeof allowed !== 'function' || !allowed('contacts') || !m.from || !m.from.address) return; const p = rd.querySelector('.mfrom'); if (!p) return; try { const l = await ctLoad(), have = ctFind(l, m.from.address); if (!rd.contains(p)) return; const a = document.createElement('a'); a.href = '#'; a.style.cssText = 'color:var(--accent);font-size:13px;margin-left:8px'; a.textContent = have ? 'In contacts' : 'Save contact'; a.onclick = e => { e.preventDefault(); if (have) location.hash = '#/contacts/c/' + have.id; else ctEdit(null, { name: m.from.name || '', org: '', emails: [{ label: '', value: m.from.address }], phones: [], address: '', birthday: '', notes: '' }); }; p.appendChild(a); } catch {} }