Last active
August 10, 2026 00:42
-
-
Save e-volusian/59acd6269c3738e2349cd14338a9069a to your computer and use it in GitHub Desktop.
Fastmail userscript to add events to calendar
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Fastmail Smart Schedule | |
| // @namespace fastmail-improver | |
| // @version 1.13.0 | |
| // @description Detect appointment phrases in the open email and add them to your calendar | |
| // @match https://app.fastmail.com/* | |
| // @match https://betaapp.fastmail.com/* | |
| // @run-at document-idle | |
| // @grant none | |
| // @noframes | |
| // @downloadURL https://gist.githubusercontent.com/e-volusian/59acd6269c3738e2349cd14338a9069a/raw/fastmail-smart-schedule.user.js | |
| // @updateURL https://gist.githubusercontent.com/e-volusian/59acd6269c3738e2349cd14338a9069a/raw/fastmail-smart-schedule.user.js | |
| // @supportURL https://gist.github.com/e-volusian/59acd6269c3738e2349cd14338a9069a | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // Shared JMAP transport for both userscripts. | |
| // | |
| // Two backends, tried in order: Fastmail's own in-page client when the script | |
| // runs in the page world, and a direct fetch using the session in localStorage | |
| // when it runs in a sandboxed userscript world. | |
| // Only these hosts may receive the session bearer token. Fastmail serves the | |
| // JMAP endpoint from a sibling host, so same-origin alone is too strict. | |
| const JMAP_ALLOWED_HOSTS = ['fastmail.com', 'fastmail.fm', 'messagingengine.com']; | |
| /** | |
| * Whether a URL is safe to send the session access token to. | |
| * | |
| * `apiUrl` comes from localStorage, which any script on the page can write. | |
| * Without this check a poisoned session entry would exfiltrate a live token. | |
| * | |
| * @param {string} url Absolute or relative URL to test. | |
| * @param {string} [pageHost] Host to treat as trusted; defaults to the page's. | |
| * @returns {boolean} True when the URL is https and on an allowed host. | |
| */ | |
| function jmapUrlAllowed(url, pageHost) { | |
| if (!String(url == null ? '' : url).trim()) return false; | |
| let parsed; | |
| try { parsed = new URL(url, 'https://' + (pageHost || 'invalid.example')); } | |
| catch { return false; } | |
| if (parsed.protocol !== 'https:') return false; | |
| if (pageHost && parsed.hostname === pageHost) return true; | |
| return JMAP_ALLOWED_HOSTS.some(h => | |
| parsed.hostname === h || parsed.hostname.endsWith('.' + h)); | |
| } | |
| /** | |
| * @param {object} accounts JMAP account map, id -> account. | |
| * @param {string} capability Capability URN the account must advertise. | |
| * @returns {string|null} The first matching account id, or null. | |
| */ | |
| function jmapFindAccountId(accounts, capability) { | |
| for (const [id, acct] of Object.entries(accounts || {})) { | |
| if ((acct.accountCapabilities || {})[capability]) return id; | |
| } | |
| return null; | |
| } | |
| /** @returns {string} The `u=` account param from the page URL, or ''. */ | |
| function jmapUParam() { | |
| return new URLSearchParams(location.search).get('u') || ''; | |
| } | |
| /** | |
| * @param {Array<object>} sessions Parsed `sessions` localStorage value. | |
| * @param {string} uParam Account param to match on. | |
| * @returns {object|null} The matching session, the first one, or null. | |
| */ | |
| function jmapPickSession(sessions, uParam) { | |
| if (!Array.isArray(sessions) || sessions.length === 0) return null; | |
| return sessions.find(s => s.accounts && s.accounts['u' + uParam]) || sessions[0]; | |
| } | |
| /** | |
| * Read the current Fastmail session from localStorage. | |
| * | |
| * @returns {{apiUrl: string, accessToken: string, accounts: object}|null} | |
| * Null when absent, unparseable, or missing credentials. | |
| */ | |
| function jmapReadSession() { | |
| let sessions; | |
| try { sessions = JSON.parse(localStorage.getItem('sessions')); } | |
| catch { return null; } | |
| if (!Array.isArray(sessions) || sessions.length === 0) return null; | |
| const s = jmapPickSession(sessions, jmapUParam()); | |
| return (s && s.apiUrl && s.accessToken) ? s : null; | |
| } | |
| /** | |
| * @param {object} data Parsed JMAP response body. | |
| * @returns {object} The first method response's arguments. | |
| * @throws {Error} When the response is empty or the method errored. | |
| */ | |
| function jmapUnwrapResponse(data) { | |
| const [name, resp] = (data.methodResponses || [])[0] || []; | |
| if (!name) throw new Error('empty JMAP response'); | |
| if (name === 'error') throw new Error('JMAP error: ' + ((resp || {}).type || 'unknown')); | |
| return resp || {}; | |
| } | |
| /** | |
| * POST a single JMAP method call. Needs the session cookies AND the bearer | |
| * token, plus the `u=` param. | |
| * | |
| * @param {object} session Session from {@link jmapReadSession}. | |
| * @param {string} capability Capability URN to declare in `using`. | |
| * @param {string} method JMAP method name, e.g. `Note/get`. | |
| * @param {object} args Method arguments. | |
| * @returns {Promise<object>} The unwrapped method response. | |
| * @throws {Error} On a disallowed apiUrl, a non-2xx status, or a JMAP error. | |
| */ | |
| async function jmapFetch(session, capability, method, args) { | |
| if (!jmapUrlAllowed(session.apiUrl, location.hostname)) { | |
| throw new Error('refusing to send credentials to ' + session.apiUrl); | |
| } | |
| const sep = session.apiUrl.includes('?') ? '&' : '?'; | |
| const res = await fetch(session.apiUrl + sep + 'u=' + jmapUParam(), { | |
| method: 'POST', | |
| credentials: 'include', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': 'Bearer ' + session.accessToken, | |
| }, | |
| body: JSON.stringify({ | |
| using: ['urn:ietf:params:jmap:core', capability], | |
| methodCalls: [[method, args, '0']], | |
| }), | |
| }); | |
| if (!res.ok) throw new Error('JMAP HTTP ' + res.status); | |
| return jmapUnwrapResponse(await res.json()); | |
| } | |
| /** | |
| * Direct page access; only works when the script runs in the page world. | |
| * | |
| * @returns {{accounts: object, call: function}|null} Null when unavailable. | |
| */ | |
| function jmapPageBackend() { | |
| const FM = window.FastMail; | |
| if (!FM || !FM.callJMAPMethod || !FM.auth) return null; | |
| const auth = FM.auth; | |
| const accounts = (auth.get ? auth.get('accounts') : auth.accounts) || {}; | |
| return { | |
| accounts, | |
| call: (method, args) => Promise.resolve(FM.callJMAPMethod(method, args)), | |
| }; | |
| } | |
| /** | |
| * Fetch-based fallback. Works from a sandboxed userscript world, since | |
| * localStorage is shared with the page. | |
| * | |
| * @param {string} capability Capability URN to declare on every call. | |
| * @returns {{accounts: object, call: function}|null} Null with no session. | |
| */ | |
| function jmapSessionBackend(capability) { | |
| const session = jmapReadSession(); | |
| if (!session) return null; | |
| return { | |
| accounts: session.accounts || {}, | |
| // Re-read the session per call so a rotated token is picked up. | |
| call: (method, args) => | |
| jmapFetch(jmapReadSession() || session, capability, method, args), | |
| }; | |
| } | |
| /** | |
| * @param {string} capability Capability URN the account must advertise. | |
| * @returns {{accounts: object, call: function}|null} The first backend with a | |
| * capable account, or null. | |
| */ | |
| function jmapBackendFor(capability) { | |
| const backends = [jmapPageBackend(), jmapSessionBackend(capability)].filter(Boolean); | |
| return backends.find(b => jmapFindAccountId(b.accounts, capability)) || null; | |
| } | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = { | |
| jmapUrlAllowed, jmapFindAccountId, jmapPickSession, | |
| jmapUnwrapResponse, jmapBackendFor, | |
| }; | |
| } | |
| const FMS_NUM_WORDS = { | |
| a: 1, an: 1, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, | |
| seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12, | |
| }; | |
| const FMS_RELATIVE_RE = | |
| /\bin\s+(\d{1,3}|a|an|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\s+(day|week|month|year)s?\b/gi; | |
| /** | |
| * @param {string} word A digit run or an English number word. | |
| * @returns {number} The amount, or 0 when unrecognised. | |
| */ | |
| function fmsParseAmount(word) { | |
| const w = String(word).toLowerCase(); | |
| return FMS_NUM_WORDS[w] || parseInt(w, 10) || 0; | |
| } | |
| /** | |
| * Add months in place, clamping to the last day (Aug 31 + 1 month -> Sep 30). | |
| * | |
| * @param {Date} d Date to shift; mutated. | |
| * @param {number} n Months to add, may be negative. | |
| */ | |
| function fmsAddMonths(d, n) { | |
| const day = d.getDate(); | |
| d.setDate(1); | |
| d.setMonth(d.getMonth() + n); | |
| const last = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); | |
| d.setDate(Math.min(day, last)); | |
| } | |
| /** | |
| * @param {Date} base Starting point; not mutated. | |
| * @param {number} amount How many units to add. | |
| * @param {string} unit One of `day`, `week`, `month`, `year`. | |
| * @returns {Date} A new date. | |
| */ | |
| function fmsAddToDate(base, amount, unit) { | |
| const d = new Date(base.getTime()); | |
| if (unit === 'day') d.setDate(d.getDate() + amount); | |
| else if (unit === 'week') d.setDate(d.getDate() + amount * 7); | |
| else if (unit === 'month') fmsAddMonths(d, amount); | |
| else if (unit === 'year') fmsAddMonths(d, amount * 12); | |
| return d; | |
| } | |
| /** | |
| * Find "in 3 months" / "in three weeks" style phrases. | |
| * | |
| * @param {string} text Text to scan. | |
| * @param {Date} baseDate The email's sent date. | |
| * @returns {Array<object>} Hits, deduped by amount and unit. | |
| */ | |
| function fmsFindRelativeDates(text, baseDate) { | |
| const found = []; | |
| const seen = new Set(); | |
| for (const m of String(text).matchAll(FMS_RELATIVE_RE)) { | |
| const amount = fmsParseAmount(m[1]); | |
| const unit = m[2].toLowerCase(); | |
| const key = amount + unit; | |
| if (!amount || seen.has(key)) continue; | |
| seen.add(key); | |
| found.push({ | |
| phrase: m[0], amount, unit, kind: 'relative', | |
| date: fmsAddToDate(baseDate, amount, unit), index: m.index, | |
| }); | |
| } | |
| return found; | |
| } | |
| /** | |
| * @param {string} title The `title` attribute on `.v-MessageCard-time`, e.g. | |
| * "Tuesday, August 04, 2026 15:24". | |
| * @returns {?Date} The parsed date, or null when unparseable. | |
| */ | |
| function fmsParseSentDate(title) { | |
| const d = new Date(String(title || '').replace(/^[A-Za-z]+,\s*/, '')); | |
| return isNaN(d.getTime()) ? null : d; | |
| } | |
| /** | |
| * @param {Date} d Date to render. | |
| * @returns {string} A locale-formatted date, e.g. "Wed, Oct 28, 2026". | |
| */ | |
| function fmsFormatDate(d) { | |
| return d.toLocaleDateString(undefined, | |
| { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }); | |
| } | |
| /** | |
| * @param {Date} d Date to render. | |
| * @returns {string} A JSCalendar LocalDateTime at midnight, no zone suffix. | |
| */ | |
| function fmsToLocalIso(d) { | |
| const p = n => String(n).padStart(2, '0'); | |
| return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + 'T00:00:00'; | |
| } | |
| /** | |
| * Fastmail day-view URL for a date, keeping the account (`u=`) param. With an | |
| * event id appended, Fastmail opens that event's detail panel. | |
| * | |
| * @param {Location} loc The page location. | |
| * @param {Date} d Day to open. | |
| * @param {string} [eventId] Event to select. | |
| * @returns {string} An absolute URL. | |
| */ | |
| function fmsCalendarDayUrl(loc, d, eventId) { | |
| const u = new URLSearchParams(loc.search).get('u'); | |
| return loc.origin + '/calendar/day/' + fmsToLocalIso(d).slice(0, 10) + | |
| (eventId ? '/' + eventId : '') + (u ? '?u=' + u : ''); | |
| } | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = { | |
| fmsParseAmount, fmsAddToDate, fmsFindRelativeDates, | |
| fmsParseSentDate, fmsToLocalIso, fmsFormatDate, fmsCalendarDayUrl, | |
| }; | |
| } | |
| const FMS_MONTH_NUMS = { | |
| jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, | |
| jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12, | |
| }; | |
| const FMS_MONTH_SRC = | |
| 'jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?' + | |
| '|jul(?:y)?|aug(?:ust)?|sep(?:t|tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?'; | |
| // How to read "10/11": month-first (US) or day-first (most of the world). | |
| const FMS_DATE_ORDER_MDY = 'MDY'; | |
| const FMS_DATE_ORDER_DMY = 'DMY'; | |
| /** | |
| * @param {string} name Month name or three-letter prefix. | |
| * @returns {number} 1-12, or 0 when unrecognised. | |
| */ | |
| function fmsMonthNum(name) { | |
| return FMS_MONTH_NUMS[String(name).slice(0, 3).toLowerCase()] || 0; | |
| } | |
| /** | |
| * @param {number} y Full year. | |
| * @param {number} m1 Month, 1-12. | |
| * @param {number} day Day of month. | |
| * @returns {?Date} The date, or null when the combination is not real. | |
| */ | |
| function fmsMakeDate(y, m1, day) { | |
| const last = new Date(y, m1, 0).getDate(); | |
| if (m1 < 1 || m1 > 12 || day < 1 || day > last) return null; | |
| return new Date(y, m1 - 1, day); | |
| } | |
| // How far behind the base date an undated month/day still reads as the date | |
| // just gone rather than next year's. | |
| const FMS_RECENT_PAST_DAYS = 30; | |
| /** | |
| * A month/day with no year lands on its next occurrence after the base date, | |
| * except when it fell within the last {@link FMS_RECENT_PAST_DAYS} days. | |
| * | |
| * Rolling every past date forward turns a delivery estimate that slipped by a | |
| * day -- "Previously expected July 17" read on the 21st -- into a suggestion | |
| * twelve months out. A date that recently passed is that date; one months | |
| * behind is far likelier to mean the year ahead, which is what an undated | |
| * "Jan 2" read in August means. The previous year is worth reading for the | |
| * same reason: a January mail naming a December date means the one just gone. | |
| * | |
| * @param {number} m1 Month, 1-12. | |
| * @param {number} day Day of month. | |
| * @param {Date} base Date to resolve against. | |
| * @returns {number} The year to use. | |
| */ | |
| function fmsYearFor(m1, day, base) { | |
| const y = base.getFullYear(); | |
| for (const candidate of [y, y - 1]) { | |
| const behind = base - new Date(candidate, m1 - 1, day); | |
| if (behind > 0 && behind <= FMS_RECENT_PAST_DAYS * 86400000) return candidate; | |
| } | |
| return new Date(y, m1 - 1, day) < base ? y + 1 : y; | |
| } | |
| /** | |
| * @param {string} text Text to scan. | |
| * @returns {Array<object>} Hits for unambiguous `2026-10-28` dates. | |
| */ | |
| function fmsFindIsoDates(text) { | |
| const hits = []; | |
| for (const m of text.matchAll(/\b(20\d{2})-(\d{1,2})-(\d{1,2})\b/g)) { | |
| const d = fmsMakeDate(+m[1], +m[2], +m[3]); | |
| if (d) hits.push({ phrase: m[0], date: d, kind: 'absolute', index: m.index }); | |
| } | |
| return hits; | |
| } | |
| /** | |
| * Slash-separated dates, whose field order is genuinely ambiguous: `10/11` | |
| * is October 11 to a US sender and 10 November to most others. The caller | |
| * supplies the reading; there is no way to infer it from the text. | |
| * | |
| * @param {string} text Text to scan. | |
| * @param {Date} base Date to resolve bare month/day against. | |
| * @param {string} [order] `MDY` (default) or `DMY`. | |
| * @returns {Array<object>} Hits, each carrying the matched phrase. | |
| */ | |
| function fmsFindNumericDates(text, base, order = FMS_DATE_ORDER_MDY) { | |
| const hits = []; | |
| for (const m of text.matchAll(/\b(\d{1,2})\/(\d{1,2})(?:\/(\d{2}|20\d{2}))?\b/g)) { | |
| // A component above 12 cannot be a month, so it settles the order by | |
| // itself: 24/07 is the 24th whatever the reader's convention. The setting | |
| // only decides genuinely ambiguous pairs like 10/11. | |
| const dayFirst = +m[1] > 12 ? true | |
| : +m[2] > 12 ? false | |
| : order === FMS_DATE_ORDER_DMY; | |
| const mo = +m[dayFirst ? 2 : 1], day = +m[dayFirst ? 1 : 2]; | |
| let y = m[3] ? +m[3] : fmsYearFor(mo, day, base); | |
| if (y < 100) y += 2000; | |
| const d = fmsMakeDate(y, mo, day); | |
| if (d) hits.push({ phrase: m[0], date: d, kind: 'absolute', index: m.index }); | |
| } | |
| return hits; | |
| } | |
| /** | |
| * @param {string} text Text to scan. | |
| * @param {Date} base Date to resolve a missing year against. | |
| * @returns {Array<object>} Hits for `Oct 28`, `October 28th, 2026`, etc. | |
| */ | |
| function fmsFindNamedDates(text, base) { | |
| const hits = []; | |
| const add = (m, mo, day, year) => { | |
| const d = fmsMakeDate(year || fmsYearFor(mo, day, base), mo, day); | |
| if (d) hits.push({ phrase: m[0], date: d, kind: 'absolute', index: m.index }); | |
| }; | |
| // "July 24", "Jul 24, 2026" | |
| const monthFirst = new RegExp( | |
| '\\b(' + FMS_MONTH_SRC + ')\\.?\\s+(\\d{1,2})(?:st|nd|rd|th)?(?:,?\\s+(20\\d{2}))?\\b', 'gi'); | |
| for (const m of text.matchAll(monthFirst)) { | |
| add(m, fmsMonthNum(m[1]), +m[2], m[3] && +m[3]); | |
| } | |
| // "24 July", "24th Jul 2026" -- how most of the world writes it, and how a | |
| // day range reads once its start is dashed off: "24-26 Jul". | |
| const dayFirst = new RegExp( | |
| '\\b(\\d{1,2})(?:st|nd|rd|th)?\\s+(' + FMS_MONTH_SRC + ')\\.?(?:,?\\s+(20\\d{2}))?\\b', 'gi'); | |
| for (const m of text.matchAll(dayFirst)) { | |
| add(m, fmsMonthNum(m[2]), +m[1], m[3] && +m[3]); | |
| } | |
| return hits; | |
| } | |
| /** | |
| * @param {string} text Text to scan. | |
| * @param {Date} base Date to resolve against. | |
| * @param {string} [order] Field order for slash dates; see | |
| * {@link fmsFindNumericDates}. | |
| * @returns {Array<object>} All absolute-date hits, most specific format first. | |
| */ | |
| function fmsFindAbsoluteDates(text, base, order) { | |
| return [ | |
| ...fmsFindIsoDates(text), | |
| ...fmsFindNumericDates(text, base, order), | |
| ...fmsFindNamedDates(text, base), | |
| ]; | |
| } | |
| /** | |
| * "end of 10/2026", "end of October", "end of the month" -> last day of month. | |
| * | |
| * @param {string} text Text to scan. | |
| * @param {Date} base Date to resolve a missing year or month against. | |
| * @returns {Array<object>} Month-end hits. | |
| */ | |
| function fmsFindMonthEnds(text, base) { | |
| const hits = []; | |
| const re = new RegExp( | |
| '\\bend\\s+of\\s+(?:(\\d{1,2})\\/(20\\d{2})|(' + FMS_MONTH_SRC + | |
| ')\\.?(?:\\s+(20\\d{2}))?|the\\s+month)\\b', 'gi'); | |
| for (const m of text.matchAll(re)) { | |
| let mo, y; | |
| if (m[1]) { mo = +m[1]; y = +m[2]; } | |
| else if (m[3]) { | |
| mo = fmsMonthNum(m[3]); | |
| y = m[4] ? +m[4] : base.getFullYear(); | |
| if (!m[4] && new Date(y, mo, 0) < base) y += 1; | |
| } else { mo = base.getMonth() + 1; y = base.getFullYear(); } | |
| if (mo < 1 || mo > 12) continue; | |
| hits.push({ phrase: m[0], date: new Date(y, mo, 0), kind: 'month-end', index: m.index }); | |
| } | |
| return hits; | |
| } | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = { | |
| fmsMonthNum, fmsMakeDate, fmsYearFor, fmsFindAbsoluteDates, fmsFindMonthEnds, | |
| fmsFindNumericDates, FMS_DATE_ORDER_MDY, FMS_DATE_ORDER_DMY, | |
| }; | |
| } | |
| const FMS_WEEKDAYS = { | |
| sunday: 0, monday: 1, tuesday: 2, wednesday: 3, | |
| thursday: 4, friday: 5, saturday: 6, | |
| // Carriers abbreviate: "Arriving by Thu, Jul 24", "Scheduled delivery: Fri". | |
| sun: 0, mon: 1, tue: 2, tues: 2, wed: 3, weds: 3, | |
| thu: 4, thur: 4, thurs: 4, fri: 5, sat: 6, | |
| }; | |
| // Longest first, so "thursday" is never truncated to "thu". | |
| const FMS_WEEKDAY_SRC = Object.keys(FMS_WEEKDAYS) | |
| .sort((a, b) => b.length - a.length).join('|'); | |
| // How far after a date phrase to look for a clock time, and how far before it | |
| // to look for deadline wording. | |
| const FMS_TIME_LOOKAHEAD_CHARS = 40; | |
| const FMS_DEADLINE_LOOKBEHIND_CHARS = 30; | |
| /** | |
| * "this X" = soonest X after base; "next X" = one week beyond that. | |
| * | |
| * @param {Date} base Date to count from. | |
| * @param {number} target Weekday index, 0 = Sunday. | |
| * @param {boolean} extraWeek Add a week, for "next". | |
| * @returns {Date} Midnight on the resolved day. | |
| */ | |
| function fmsNextWeekday(base, target, extraWeek) { | |
| const d = new Date(base.getFullYear(), base.getMonth(), base.getDate()); | |
| let diff = (target - d.getDay() + 7) % 7; | |
| if (diff === 0) diff = 7; | |
| d.setDate(d.getDate() + diff + (extraWeek ? 7 : 0)); | |
| return d; | |
| } | |
| /** | |
| * @param {string} text Text to scan. | |
| * @param {Date} base Date to resolve against. | |
| * @returns {Array<object>} Hits for "this Tuesday", "next Friday", "on Monday". | |
| */ | |
| function fmsFindWeekdays(text, base) { | |
| const hits = []; | |
| const re = new RegExp( | |
| '\\b(this|next|on)\\s+(' + FMS_WEEKDAY_SRC + ')\\b', 'gi'); | |
| for (const m of text.matchAll(re)) { | |
| const day = FMS_WEEKDAYS[m[2].toLowerCase()]; | |
| const extra = m[1].toLowerCase() === 'next'; | |
| hits.push({ | |
| phrase: m[0], date: fmsNextWeekday(base, day, extra), | |
| kind: 'weekday', index: m.index, | |
| }); | |
| } | |
| return hits; | |
| } | |
| /** | |
| * @param {string} text Text to scan. | |
| * @param {Date} base Date to resolve against. | |
| * @returns {Array<object>} Hits for "tomorrow", "next week", "next month". | |
| */ | |
| function fmsFindSimpleRelatives(text, base) { | |
| const specs = [ | |
| [/\btomorrow\b/gi, b => fmsAddToDate(b, 1, 'day')], | |
| [/\bnext\s+week\b/gi, b => fmsAddToDate(b, 1, 'week')], | |
| [/\bnext\s+month\b/gi, b => fmsAddToDate(b, 1, 'month')], | |
| ]; | |
| const hits = []; | |
| for (const [re, calc] of specs) { | |
| for (const m of text.matchAll(re)) { | |
| hits.push({ phrase: m[0], date: calc(base), kind: 'relative', index: m.index }); | |
| } | |
| } | |
| return hits; | |
| } | |
| /** | |
| * Find a clock time like "at 3pm" or "15:00" shortly after a date phrase. | |
| * | |
| * @param {string} text Full text being scanned. | |
| * @param {number} index Where the date phrase starts. | |
| * @param {number} phraseLen Length of the date phrase. | |
| * @param {number} [range] How many characters past the phrase to search. | |
| * @returns {?{hours: number, minutes: number}} The time, or null. | |
| */ | |
| function fmsTimeNear(text, index, phraseLen, range = FMS_TIME_LOOKAHEAD_CHARS) { | |
| const seg = text.slice(index, index + phraseLen + range); | |
| const m = seg.match(/\b(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i) || | |
| seg.match(/\b(\d{1,2}):(\d{2})\b/); | |
| if (!m) return null; | |
| let h = +m[1]; | |
| const minutes = +(m[2] || 0); | |
| const ap = (m[3] || '').toLowerCase(); | |
| if (ap === 'pm' && h < 12) h += 12; | |
| if (ap === 'am' && h === 12) h = 0; | |
| return (h > 23 || minutes > 59) ? null : { hours: h, minutes }; | |
| } | |
| const FMS_DEADLINE_RE = | |
| /(due\s+(?:by|on)|expir(?:es?|ing|ation)|renew(?:al)?|no\s+later\s+than|deadline|before)[^.\n]{0,15}$/i; | |
| /** | |
| * Deadline-flavored wording just before the phrase enables the reminder offer. | |
| * | |
| * @param {string} text Full text being scanned. | |
| * @param {number} index Where the date phrase starts. | |
| * @returns {boolean} Whether the run-up reads like a deadline. | |
| */ | |
| function fmsIsDeadline(text, index) { | |
| return FMS_DEADLINE_RE.test( | |
| text.slice(Math.max(0, index - FMS_DEADLINE_LOOKBEHIND_CHARS), index)); | |
| } | |
| /** | |
| * @param {Date} d Any date. | |
| * @returns {Date} Midnight at the start of that day. | |
| */ | |
| function fmsDayOf(d) { | |
| return new Date(d.getFullYear(), d.getMonth(), d.getDate()); | |
| } | |
| // Delivery mail rarely says "on Tuesday" -- it says "Arriving Tuesday". A bare | |
| // weekday is far too common to detect on its own, so it only counts when one | |
| // of these leads into it. | |
| const FMS_ARRIVAL_SRC = [ | |
| // "Arriving Friday", "Arrives on Friday", "Estimated to arrive by Thu" | |
| 'arriv(?:ing|es|ed|al|e)(?:\\s+(?:on|by))?', | |
| 'estimated\\s+to\\s+arriv(?:e|ing)(?:\\s+(?:on|by))?', | |
| // "Estimated delivery", "Expected delivery date", "Scheduled delivery" | |
| '(?:estimated|expected|scheduled)\\s+delivery(?:\\s+date)?', | |
| 'delivery\\s+(?:date|expected|scheduled|by)', | |
| 'expected\\s+(?:by|on)', | |
| // "Delivered on Friday", "will be delivered by Friday" | |
| 'deliver(?:s|ed|y)?\\s+(?:on|by)', | |
| 'scheduled\\s+(?:for|on)', | |
| // Amazon and eBay both promise this way. | |
| 'get\\s+it\\s+by', | |
| // A missed attempt needs chasing as much as an arrival needs meeting. | |
| 'delivery\\s+attempted|attempted\\s+delivery', | |
| ].join('|'); | |
| /** | |
| * "Arriving Tuesday", "Estimated delivery: Aug 12", "Out for delivery". | |
| * | |
| * @param {string} text Text to scan. | |
| * @param {Date} base Date to resolve against. | |
| * @returns {Array<object>} Arrival hits, one per phrase. | |
| */ | |
| function fmsFindArrivals(text, base) { | |
| const hits = []; | |
| const re = new RegExp( | |
| '\\b(?:' + FMS_ARRIVAL_SRC + ')\\b[\\s:,.-]*' + | |
| '(today|tomorrow|' + FMS_WEEKDAY_SRC + ')\\b', | |
| 'gi'); | |
| for (const m of text.matchAll(re)) { | |
| const word = m[1].toLowerCase(); | |
| let date; | |
| if (word === 'today') date = fmsDayOf(base); | |
| else if (word === 'tomorrow') date = fmsAddToDate(fmsDayOf(base), 1, 'day'); | |
| else date = fmsNextWeekday(base, FMS_WEEKDAYS[word], false); | |
| hits.push({ phrase: m[0], date, kind: 'arrival', index: m.index }); | |
| } | |
| // No date at all: the carrier means today. | |
| for (const m of text.matchAll(/\bout\s+for\s+delivery\b/gi)) { | |
| hits.push({ | |
| phrase: m[0], date: fmsDayOf(base), kind: 'arrival', index: m.index, | |
| }); | |
| } | |
| return hits; | |
| } | |
| const FMS_IN_HOURS_RE = | |
| /\bin\s+(\d{1,2}|an?|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\s+hours?\b/gi; | |
| // Sub-day anchors. 8/13/18 match the clock times Fastmail's own snooze | |
| // preferences offer, so a suggestion lands where the native options would. | |
| const FMS_HOUR_MORNING = 8; | |
| const FMS_HOUR_AFTERNOON = 13; | |
| const FMS_HOUR_EOD = 17; | |
| const FMS_HOUR_EVENING = 18; | |
| /** | |
| * Phrases that name a part of a day. Unlike the calendar detectors these can | |
| * resolve to the send day itself, because a snooze can be same-day. | |
| * | |
| * @param {string} text Text to scan. | |
| * @param {Date} base Date to resolve against. | |
| * @returns {Array<object>} Hits carrying an explicit `time`. | |
| */ | |
| function fmsFindSubDay(text, base) { | |
| const day = fmsDayOf(base); | |
| const next = fmsAddToDate(day, 1, 'day'); | |
| const at = hours => ({ hours, minutes: 0 }); | |
| const specs = [ | |
| [/\bthis\s+afternoon\b/gi, day, at(FMS_HOUR_AFTERNOON)], | |
| [/\b(?:this\s+evening|tonight)\b/gi, day, at(FMS_HOUR_EVENING)], | |
| [/\b(?:by\s+)?(?:the\s+)?end\s+of\s+(?:the\s+)?day\b|\bEOD\b/g, day, at(FMS_HOUR_EOD)], | |
| [/\b(?:first\s+thing\s+)?tomorrow\s+morning\b/gi, next, at(FMS_HOUR_MORNING)], | |
| [/\btomorrow\s+afternoon\b/gi, next, at(FMS_HOUR_AFTERNOON)], | |
| [/\btomorrow\s+(?:evening|night)\b/gi, next, at(FMS_HOUR_EVENING)], | |
| ]; | |
| const hits = []; | |
| for (const [re, date, time] of specs) { | |
| for (const m of text.matchAll(re)) { | |
| hits.push({ | |
| phrase: m[0], date: new Date(date.getTime()), kind: 'subday', | |
| index: m.index, time, fixedTime: true, | |
| }); | |
| } | |
| } | |
| for (const m of text.matchAll(FMS_IN_HOURS_RE)) { | |
| const amount = fmsParseAmount(m[1]); | |
| if (!amount || amount > 48) continue; | |
| const when = new Date(base.getTime() + amount * 3600000); | |
| hits.push({ | |
| phrase: m[0], date: fmsDayOf(when), kind: 'subday', index: m.index, | |
| time: { hours: when.getHours(), minutes: when.getMinutes() }, | |
| fixedTime: true, | |
| }); | |
| } | |
| return hits; | |
| } | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = { | |
| fmsNextWeekday, fmsFindWeekdays, fmsFindSimpleRelatives, fmsTimeNear, fmsIsDeadline, | |
| fmsFindArrivals, fmsFindSubDay, fmsDayOf, | |
| }; | |
| } | |
| const FMS_MAX_HITS = 5; | |
| /** | |
| * Keep the first hit for each calendar day. Detectors run most-specific first, | |
| * so an explicit date beats a vaguer phrase resolving to the same day. | |
| * | |
| * @param {Array<object>} hits Detected hits. | |
| * @returns {Array<object>} One hit per distinct day, in input order. | |
| */ | |
| function fmsDedupeByDay(hits) { | |
| const seen = new Map(); | |
| for (const h of hits) { | |
| const key = h.date.toDateString(); | |
| if (!seen.has(key)) seen.set(key, h); | |
| } | |
| return [...seen.values()]; | |
| } | |
| /** | |
| * Find every date phrase in an email body. | |
| * | |
| * @param {string} text Subject plus body text. | |
| * @param {Date} base The email's sent date; all relative phrases resolve | |
| * against it. | |
| * @param {{dateOrder?: string}} [opts] `dateOrder` picks how `10/11` reads; | |
| * see {@link fmsFindNumericDates}. | |
| * @returns {Array<object>} Up to {@link FMS_MAX_HITS} hits sorted by date, | |
| * each with `phrase`, `date`, `kind`, `index`, `time` and `deadline`. | |
| */ | |
| function fmsDetectAll(text, base, opts = {}) { | |
| const t = String(text); | |
| const all = [ | |
| ...fmsFindAbsoluteDates(t, base, opts.dateOrder), | |
| ...fmsFindMonthEnds(t, base), | |
| ...fmsFindWeekdays(t, base), | |
| ...fmsFindSimpleRelatives(t, base), | |
| ...fmsFindRelativeDates(t, base), | |
| ]; | |
| // Strictly after the send day: a hit on the send date itself is almost | |
| // always the message's own Date header echoed in the body. | |
| const dayStart = new Date(base.getFullYear(), base.getMonth(), base.getDate()); | |
| const future = all.filter(h => h.date > dayStart); | |
| for (const h of future) { | |
| h.time = fmsTimeNear(t, h.index || 0, h.phrase.length); | |
| h.deadline = fmsIsDeadline(t, h.index || 0); | |
| } | |
| return fmsDedupeByDay(future) | |
| .sort((a, b) => a.date - b.date) | |
| .slice(0, FMS_MAX_HITS); | |
| } | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = { fmsDetectAll, fmsDedupeByDay }; | |
| } | |
| // Snooze suggestions: the dates already found in a message, turned into | |
| // wake-up times worth offering in Fastmail's snooze menu. | |
| // Fastmail's own picker refuses anything further out than this. | |
| const FMS_SNOOZE_MAX_DAYS = 512; | |
| // How many dates to build groups for, at most. | |
| const FMS_SNOOZE_MAX = 3; | |
| // A suggestion this close to one of Fastmail's built-in options is that | |
| // option, so it is dropped rather than duplicated. | |
| const FMS_SNOOZE_NEAR_MS = 30 * 60 * 1000; | |
| // Nothing in a one-time-code mail is worth waking up for, and such mail is | |
| // full of times ("expires in 10 minutes") that would otherwise be detected. | |
| const FMS_SNOOZE_SKIP_RE = | |
| /\b(?:verification|security|access)\s+code\b|\bone[-\s]?time\s+(?:code|password|passcode|pin|link)\b|\bexpires?\s+in\s+\d+\s+minutes?\b|\bOTP\b|\btwo[-\s]?factor\b/i; | |
| /** | |
| * Every date phrase in a message that a snooze could target. | |
| * | |
| * The calendar path ({@link fmsDetectAll}) drops anything on or before the | |
| * send day and forgets the time of day. A snooze needs both: "this evening" | |
| * and "arriving today" are the most useful targets a message can offer. | |
| * | |
| * @param {string} text Subject plus body text. | |
| * @param {Date} base The message's sent date. | |
| * @param {{dateOrder?: string}} [opts] `dateOrder` picks how `10/11` reads. | |
| * @returns {Array<object>} Hits sorted by date, one per calendar day. | |
| */ | |
| function fmsSnoozeHits(text, base, opts = {}) { | |
| const t = String(text); | |
| // Most specific first: fmsDedupeByDay keeps the first hit for each day, and | |
| // a phrase naming a time of day beats a bare date on the same day. | |
| const all = [ | |
| ...fmsFindSubDay(t, base), | |
| ...fmsFindArrivals(t, base), | |
| ...fmsFindAbsoluteDates(t, base, opts.dateOrder), | |
| ...fmsFindMonthEnds(t, base), | |
| ...fmsFindWeekdays(t, base), | |
| ...fmsFindSimpleRelatives(t, base), | |
| ...fmsFindRelativeDates(t, base), | |
| ]; | |
| for (const h of all) { | |
| if (!h.fixedTime) h.time = fmsTimeNear(t, h.index || 0, h.phrase.length); | |
| h.deadline = fmsIsDeadline(t, h.index || 0); | |
| } | |
| return fmsDedupeByDay(all).sort((a, b) => a.date - b.date); | |
| } | |
| /** | |
| * @param {Date} day Day to land on. | |
| * @param {number} minutes Clock time, in minutes since midnight. | |
| * @returns {Date} That day at that time. | |
| */ | |
| function fmsSnoozeAt(day, minutes) { | |
| const d = new Date(day.getFullYear(), day.getMonth(), day.getDate()); | |
| d.setMinutes(minutes); | |
| return d; | |
| } | |
| /** | |
| * @param {Date} d Date to render. | |
| * @returns {string} A short day label, e.g. "Oct 31". | |
| */ | |
| function fmsSnoozeDayLabel(d) { | |
| return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); | |
| } | |
| /** | |
| * @param {Date} d Date to render. | |
| * @returns {string} The date written out, e.g. "October 31". | |
| */ | |
| function fmsSnoozeDateLabel(d) { | |
| return d.toLocaleDateString(undefined, { month: 'long', day: 'numeric' }); | |
| } | |
| /** | |
| * The wake-up times one detected date is worth offering. | |
| * | |
| * Each option lands at the configured morning hour, so the row only has to | |
| * name which day it means: the date itself, the days either side of it, and | |
| * the lead time before it. The date they are all relative to is named once, | |
| * by the group heading above them, so it is left out of the labels here. | |
| * | |
| * @param {object} hit A hit from {@link fmsSnoozeHits}. | |
| * @param {object} opts Resolved settings; see {@link fmsSnoozeOptions}. | |
| * @returns {Array<{date: Date, why: string}>} Candidates, in menu order. | |
| */ | |
| function fmsSnoozeTargets(hit, opts) { | |
| // A phrase that names its own time ("tomorrow morning", "in 3 hours") is | |
| // already exact. Padding it with a day-before or morning-of option would | |
| // only add noise. | |
| if (hit.fixedTime) { | |
| return [{ | |
| date: fmsSnoozeAt(hit.date, hit.time.hours * 60 + hit.time.minutes), | |
| why: fmsSnoozeSentenceCase(hit.phrase), | |
| }]; | |
| } | |
| const back = new Date(hit.date.getTime() - opts.lead * 60000); | |
| const out = []; | |
| // A message that names a time for the date means that time; waking at the | |
| // usual morning hour instead would miss it. | |
| if (hit.time) { | |
| out.push({ | |
| date: fmsSnoozeAt(hit.date, hit.time.hours * 60 + hit.time.minutes), | |
| why: 'At the time', | |
| }); | |
| } | |
| return out.concat([ | |
| { date: fmsSnoozeAt(hit.date, opts.morning), why: 'Morning of' }, | |
| { | |
| date: fmsSnoozeAt(fmsAddToDate(hit.date, -1, 'day'), opts.morning), | |
| why: 'Day before', | |
| }, | |
| { | |
| date: fmsSnoozeAt(fmsAddToDate(hit.date, 1, 'day'), opts.morning), | |
| why: 'Day after', | |
| }, | |
| { | |
| // A lead of whole days lands at the usual morning hour; a shorter one | |
| // is meant literally, so keep the offset it names. | |
| date: opts.lead % 1440 === 0 ? fmsSnoozeAt(back, opts.morning) : back, | |
| why: fmsSnoozeLeadLabel(opts.lead) + ' before', | |
| }, | |
| ]); | |
| } | |
| /** @param {string} s Any phrase. @returns {string} The phrase, capitalized. */ | |
| function fmsSnoozeSentenceCase(s) { | |
| const t = String(s).trim().replace(/\s+/g, ' '); | |
| return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase(); | |
| } | |
| /** | |
| * @param {number} minutes A lead time in minutes. | |
| * @returns {string} How to name it, e.g. "1 week". | |
| */ | |
| function fmsSnoozeLeadLabel(minutes) { | |
| for (const [unit, size] of [['week', 10080], ['day', 1440], ['hour', 60]]) { | |
| if (minutes >= size && minutes % size === 0) { | |
| const n = minutes / size; | |
| return n + ' ' + unit + (n === 1 ? '' : 's'); | |
| } | |
| } | |
| return minutes + ' minutes'; | |
| } | |
| /** | |
| * The wake-up times Fastmail's snooze menu already offers, so suggestions can | |
| * avoid repeating one. Mirrors `FutureTimeMenuView.drawOptions`; the menu's | |
| * remembered "last custom" entry is read from Fastmail's own preferences. | |
| * | |
| * @param {Date} now Current time. | |
| * @param {?number} lastCustomDelta Fastmail's `lastUsedFutureDelta`, if known. | |
| * @returns {Array<Date>} The times the menu shows. | |
| */ | |
| function fmsSnoozeNativeTimes(now, lastCustomDelta) { | |
| const hour = new Date(now.getTime()); | |
| hour.setMinutes(0, 0, 0); | |
| const day = now.getDay(); | |
| const at = (offsetDays, hours) => { | |
| const d = new Date(hour.getTime()); | |
| d.setHours(hours, 0, 0, 0); | |
| d.setDate(d.getDate() + offsetDays); | |
| return d; | |
| }; | |
| const times = [ | |
| new Date(hour.getTime() + 3 * 3600000), // later today | |
| at(0, 18), // this evening | |
| at(1, 8), // tomorrow | |
| at(7 - ((day + 1) % 7), 8), // this weekend (Saturday) | |
| at(7 - ((day + 6) % 7), 8), // next week (Monday) | |
| ]; | |
| if (typeof lastCustomDelta === 'number') { | |
| const endOfToday = new Date(now.getTime()); | |
| endOfToday.setHours(24, 0, 0, 0); | |
| times.push(new Date(endOfToday.getTime() + lastCustomDelta)); | |
| } | |
| return times; | |
| } | |
| /** | |
| * Resolve the snooze settings into the numbers the engine works in. | |
| * | |
| * @returns {{morning: number, evening: number, lead: number, max: number}} | |
| * Clock times and lead time in minutes. | |
| */ | |
| function fmsSnoozeOptions() { | |
| return { | |
| morning: fmsParseClock(fmsReadSetting('snoozeMorning')) || 8 * 60, | |
| evening: fmsParseClock(fmsReadSetting('snoozeEvening')) || 18 * 60, | |
| lead: fmsParseOffset(fmsReadSetting('snoozeLead')) || 10080, | |
| max: FMS_SNOOZE_MAX, | |
| }; | |
| } | |
| /** | |
| * What to offer in the snooze menu for one message, one group per date found. | |
| * | |
| * Each group names its date once and lists the wake-up times built from it, so | |
| * a message mentioning several dates stays readable. | |
| * | |
| * @param {string} text Subject plus body text. | |
| * @param {Date} sent The message's sent date; relative phrases resolve | |
| * against it, so a stale "this evening" correctly falls in the past. | |
| * @param {Date} now Current time. | |
| * @param {object} [opts] Settings from {@link fmsSnoozeOptions}, plus | |
| * `dateOrder`, `native` (times to avoid) and `max` (how many dates). | |
| * @returns {Array<{date: Date, label: string, options: Array<object>}>} Groups | |
| * in the order their dates appear, soonest first. | |
| */ | |
| function fmsSnoozeGroups(text, sent, now, opts = {}) { | |
| const t = String(text); | |
| if (FMS_SNOOZE_SKIP_RE.test(t)) return []; | |
| const settings = Object.assign( | |
| { morning: 8 * 60, evening: 18 * 60, lead: 10080, max: FMS_SNOOZE_MAX }, | |
| opts); | |
| const native = opts.native || []; | |
| const latest = now.getTime() + FMS_SNOOZE_MAX_DAYS * 86400000; | |
| const groups = []; | |
| const seen = new Set(); | |
| for (const hit of fmsSnoozeHits(t, sent, settings)) { | |
| // A day that has already gone by has nothing left to offer. Today still | |
| // does -- a time later today, or the morning after -- so the comparison | |
| // is by day, and the per-option check below drops the times that passed. | |
| if (fmsDayOf(hit.date).getTime() < fmsDayOf(now).getTime()) continue; | |
| const options = []; | |
| for (const target of fmsSnoozeTargets(hit, settings)) { | |
| const ms = target.date.getTime(); | |
| if (ms <= now.getTime() || ms > latest) continue; | |
| if (seen.has(ms)) continue; | |
| if (native.some(n => Math.abs(n - ms) < FMS_SNOOZE_NEAR_MS)) continue; | |
| seen.add(ms); | |
| options.push(target); | |
| } | |
| if (!options.length) continue; | |
| groups.push({ date: hit.date, label: fmsSnoozeDateLabel(hit.date), options }); | |
| if (groups.length >= settings.max) break; | |
| } | |
| return groups; | |
| } | |
| /** | |
| * The same suggestions as {@link fmsSnoozeGroups}, flattened. | |
| * | |
| * @param {string} text Subject plus body text. | |
| * @param {Date} sent The message's sent date. | |
| * @param {Date} now Current time. | |
| * @param {object} [opts] As {@link fmsSnoozeGroups}. | |
| * @returns {Array<{date: Date, why: string}>} Every option, grouping dropped. | |
| */ | |
| function fmsSnoozeSuggestions(text, sent, now, opts = {}) { | |
| return fmsSnoozeGroups(text, sent, now, opts) | |
| .reduce((all, g) => all.concat(g.options), []); | |
| } | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = { | |
| fmsSnoozeHits, fmsSnoozeTargets, fmsSnoozeNativeTimes, fmsSnoozeSuggestions, | |
| fmsSnoozeGroups, fmsSnoozeDayLabel, fmsSnoozeDateLabel, fmsSnoozeLeadLabel, | |
| }; | |
| } | |
| const FMS_CAL_CAP = 'urn:ietf:params:jmap:calendars'; | |
| // The account and its default calendar effectively never change during a | |
| // session, but fmsFindEventByUid runs on every banner render -- so cache them | |
| // rather than re-fetching the whole calendar list each time. | |
| const FMS_CONTEXT_TTL_MS = 5 * 60 * 1000; | |
| let fmsContextCache = null; | |
| /** | |
| * @param {object} accounts JMAP account map. | |
| * @returns {string|null} Id of the account holding calendars, or null. | |
| */ | |
| function fmsFindCalendarAccountId(accounts) { | |
| return jmapFindAccountId(accounts, FMS_CAL_CAP); | |
| } | |
| /** | |
| * @returns {{accounts: object, call: function}} A calendar-capable backend. | |
| * @throws {Error} When no Fastmail session exposes calendars. | |
| */ | |
| function fmsGetBackend() { | |
| const backend = jmapBackendFor(FMS_CAL_CAP); | |
| if (!backend) throw new Error('no calendar-capable Fastmail session found'); | |
| return backend; | |
| } | |
| /** | |
| * @param {{call: function}} backend Calendar backend. | |
| * @param {string} accountId Account to query. | |
| * @returns {Promise<object>} The default calendar, or the first writable one. | |
| * @throws {Error} When nothing writable exists. | |
| */ | |
| async function fmsDefaultCalendar(backend, accountId) { | |
| const res = await backend.call('Calendar/get', { accountId, ids: null }); | |
| const list = (res.list || []).filter(c => | |
| !c.myRights || c.myRights.mayWriteAll || c.myRights.mayWriteOwn); | |
| const cal = list.find(c => c.isDefault) || list[0]; | |
| if (!cal) throw new Error('no writable calendar found'); | |
| return cal; | |
| } | |
| /** @returns {Promise<{backend: object, accountId: string, calendar: object}>} */ | |
| async function fmsBuildContext() { | |
| const backend = fmsGetBackend(); | |
| const accountId = fmsFindCalendarAccountId(backend.accounts); | |
| const calendar = await fmsDefaultCalendar(backend, accountId); | |
| return { backend, accountId, calendar }; | |
| } | |
| /** | |
| * Resolve the target account and calendar, memoized for {@link | |
| * FMS_CONTEXT_TTL_MS}. A failed lookup is not cached. | |
| * | |
| * @param {boolean} [force] Bypass and replace the cached value. | |
| * @returns {Promise<{backend: object, accountId: string, calendar: object}>} | |
| */ | |
| function fmsEventContext(force) { | |
| const now = Date.now(); | |
| if (!force && fmsContextCache && now - fmsContextCache.at < FMS_CONTEXT_TTL_MS) { | |
| return fmsContextCache.promise; | |
| } | |
| const promise = fmsBuildContext(); | |
| fmsContextCache = { at: now, promise }; | |
| promise.catch(() => { | |
| if (fmsContextCache && fmsContextCache.promise === promise) fmsContextCache = null; | |
| }); | |
| return promise; | |
| } | |
| /** | |
| * @param {Date} date Event day. | |
| * @param {?{hours: number, minutes: number}} time Clock time, or null for all-day. | |
| * @returns {string} A JSCalendar LocalDateTime. | |
| */ | |
| function fmsEventStart(date, time) { | |
| if (!time) return fmsToLocalIso(date); | |
| const p = n => String(n).padStart(2, '0'); | |
| return fmsToLocalIso(date).slice(0, 11) + p(time.hours) + ':' + p(time.minutes) + ':00'; | |
| } | |
| /** | |
| * Build the JSCalendar event to create. All-day unless a time is given; then a | |
| * timed event of the configured length in the local zone. | |
| * | |
| * @param {{title: string, description: string, date: Date, | |
| * time: ?object, url: ?string, uid: ?string}} spec What the banner detected. | |
| * @param {string} calendarId Target calendar. | |
| * @returns {object} A JSCalendar Event ready for `CalendarEvent/set`. | |
| */ | |
| function fmsBuildEvent({ title, description, date, time, url, uid }, calendarId) { | |
| const event = { | |
| '@type': 'Event', | |
| // Fastmail's jscalendarbis rejects creates without this exact version. | |
| version: '2.0', | |
| // Never rendered by any client UI, so it doubles as our hidden marker. | |
| ...(uid ? { uid } : {}), | |
| calendarIds: { [calendarId]: true }, | |
| title, | |
| description, | |
| start: fmsEventStart(date, time), | |
| showWithoutTime: !time, | |
| duration: time ? fmsTimedDurationIso() : 'P1D', | |
| freeBusyStatus: time ? 'busy' : 'free', | |
| }; | |
| if (time) event.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; | |
| if (url) event.links = { l1: { '@type': 'Link', href: url, title: 'Original email' } }; | |
| // Trigger only -- Fastmail decides how the alert is delivered. | |
| const alerts = fmsAlertsFor(!time); | |
| if (alerts) event.alerts = alerts; | |
| return event; | |
| } | |
| /** | |
| * Test mode: resolve the target calendar and build the event, writing nothing. | |
| * | |
| * @param {object} spec Event spec. | |
| * @returns {Promise<{event: object, calendarName: string}>} | |
| */ | |
| async function fmsPreviewEvent(spec) { | |
| const { calendar } = await fmsEventContext(); | |
| return { | |
| event: fmsBuildEvent(spec, calendar.id), | |
| calendarName: calendar.name || '(unnamed calendar)', | |
| }; | |
| } | |
| /** | |
| * @param {Date} date A local calendar day. | |
| * @param {number} offsetDays Days to shift. | |
| * @returns {string} That day's midnight as a zoneless LocalDateTime — the only | |
| * form Fastmail's query filter accepts (a `Z` suffix is invalidArguments). | |
| */ | |
| function fmsDayIso(date, offsetDays) { | |
| return fmsToLocalIso(new Date(date.getFullYear(), date.getMonth(), | |
| date.getDate() + offsetDays)); | |
| } | |
| /** | |
| * Find the event our marker uid produced for a given day, if it still exists. | |
| * | |
| * The query window is the day padded by one day each side: the server compares | |
| * in the calendar's zone, which may differ from the browser's. | |
| * | |
| * @param {string} uid Marker uid from `fmsHitUid`. | |
| * @param {Date} date The event's expected day. | |
| * @returns {Promise<?string>} The event id, or null when no event carries the | |
| * uid. Rejects when the lookup itself fails, so callers can tell "definitely | |
| * absent" from "unknown". | |
| */ | |
| async function fmsFindEventByUid(uid, date) { | |
| const { backend, accountId } = await fmsEventContext(); | |
| const q = await backend.call('CalendarEvent/query', { accountId, | |
| filter: { after: fmsDayIso(date, -1), before: fmsDayIso(date, 2) } }); | |
| if (!q.ids || !q.ids.length) return null; | |
| const res = await backend.call('CalendarEvent/get', | |
| { accountId, ids: q.ids, properties: ['id', 'uid'] }); | |
| const hit = (res.list || []).find(e => e.uid === uid); | |
| return hit ? hit.id : null; | |
| } | |
| /** | |
| * @param {object} spec Event spec. | |
| * @returns {Promise<string>} The created event's id. | |
| * @throws {Error} When the server rejects the create. | |
| */ | |
| async function fmsCreateEvent(spec) { | |
| const { backend, accountId, calendar } = await fmsEventContext(); | |
| const res = await backend.call('CalendarEvent/set', | |
| { accountId, create: { e1: fmsBuildEvent(spec, calendar.id) } }); | |
| if (!res.created || !res.created.e1) { | |
| const why = res.notCreated && res.notCreated.e1; | |
| throw new Error('event creation failed' + (why ? ': ' + (why.type || '') : '')); | |
| } | |
| return res.created.e1.id; | |
| } | |
| // Test mode: action buttons preview what would be created instead of creating it. | |
| const FMS_TEST_KEY = 'fms.testMode'; | |
| /** @returns {boolean} Whether action buttons only preview. */ | |
| function fmsTestModeEnabled() { | |
| try { return localStorage.getItem(FMS_TEST_KEY) === '1'; } | |
| catch { return false; } | |
| } | |
| /** @param {boolean} on Turn test mode on or off; persisted per browser. */ | |
| function fmsSetTestMode(on) { | |
| try { localStorage.setItem(FMS_TEST_KEY, on ? '1' : '0'); } | |
| catch { /* storage unavailable; the toggle just won't persist */ } | |
| } | |
| /** | |
| * @param {object} event A built JSCalendar event. | |
| * @param {string} calendarName Target calendar's name. | |
| * @returns {Array<[string, string]>} [label, value] pairs for the preview modal. | |
| */ | |
| function fmsPreviewLines(event, calendarName) { | |
| const allDay = !!event.showWithoutTime; | |
| const lines = [ | |
| ['Title', event.title], | |
| ['Date', fmsFormatDate(new Date(event.start))], | |
| ]; | |
| if (!allDay) lines.push(['Time', event.start.slice(11, 16)]); | |
| lines.push( | |
| ['Duration', allDay ? '1 day' : fmsReadSetting('timedDur') + ' h'], | |
| ['Type', allDay ? 'All-day' : 'Timed'], | |
| ['Alert', fmsAlertLine(event)], | |
| ['Calendar', calendarName], | |
| ['Description', event.description], | |
| ); | |
| return lines; | |
| } | |
| // User-configurable settings, persisted per-browser in localStorage. | |
| const FMS_SET_KEYS = { | |
| alertsOn: 'fms.alertsOn', | |
| alertAllDay: 'fms.alertAllDay', | |
| alertTimed: 'fms.alertTimed', | |
| alarmTime: 'fms.alarmTime', | |
| timedDur: 'fms.timedDur', | |
| viewNewTab: 'fms.viewNewTab', | |
| dateOrder: 'fms.dateOrder', | |
| snoozeOn: 'fms.snoozeOn', | |
| snoozeMorning: 'fms.snoozeMorning', | |
| snoozeEvening: 'fms.snoozeEvening', | |
| snoozeLead: 'fms.snoozeLead', | |
| }; | |
| const FMS_SET_DEFAULTS = { | |
| alertsOn: '1', | |
| alertAllDay: '1D', | |
| alertTimed: '1H', | |
| alarmTime: '8:00', | |
| timedDur: '1', | |
| viewNewTab: '1', | |
| dateOrder: FMS_DATE_ORDER_MDY, | |
| snoozeOn: '1', | |
| snoozeMorning: '8:00', | |
| snoozeEvening: '18:00', | |
| snoozeLead: '1W', | |
| }; | |
| // Remembered-event map entries kept before the oldest are dropped. | |
| const FMS_CREATED_MAX = 200; | |
| // Fallback when the configured all-day alert clock time is unreadable. | |
| const FMS_DEFAULT_ALARM_MINUTES = 480; | |
| const FMS_UNIT_MINUTES = { m: 1, h: 60, d: 1440, w: 10080, mo: 43200 }; | |
| // "1.5H" -> 90 minutes; null when not a valid time text. | |
| function fmsParseOffset(text) { | |
| const m = String(text).trim().match(/^(\d+(?:\.\d+)?)\s*(mo|[mhdw])$/i); | |
| if (!m) return null; | |
| const minutes = Math.round(parseFloat(m[1]) * FMS_UNIT_MINUTES[m[2].toLowerCase()]); | |
| return minutes >= 1 ? minutes : null; | |
| } | |
| // "8:00" (24h clock) -> minutes since midnight; null when invalid. | |
| function fmsParseClock(text) { | |
| const m = String(text).trim().match(/^([01]?\d|2[0-3]):([0-5]\d)$/); | |
| return m ? (+m[1]) * 60 + (+m[2]) : null; | |
| } | |
| // "0.25" hours -> 15 minutes; null when not a positive number. | |
| function fmsParseHours(text) { | |
| if (!/^\d+(\.\d+)?$/.test(String(text).trim())) return null; | |
| const minutes = Math.round(parseFloat(text) * 60); | |
| return minutes >= 1 ? minutes : null; | |
| } | |
| function fmsMinutesToIso(total) { | |
| const d = Math.floor(total / 1440); | |
| const h = Math.floor((total % 1440) / 60); | |
| const mi = total % 60; | |
| let s = 'P' + (d ? d + 'D' : ''); | |
| if (h || mi || !d) s += 'T' + (h ? h + 'H' : '') + (mi || !h ? mi + 'M' : ''); | |
| return s; | |
| } | |
| /** | |
| * @param {string} name Setting key. | |
| * @param {string} v Candidate value. | |
| * @returns {boolean} Whether the value is usable for this setting. | |
| */ | |
| function fmsValidSetting(name, v) { | |
| if (name === 'alertsOn' || name === 'viewNewTab' || name === 'snoozeOn') { | |
| return v === '0' || v === '1'; | |
| } | |
| if (name === 'dateOrder') return v === FMS_DATE_ORDER_MDY || v === FMS_DATE_ORDER_DMY; | |
| if (name === 'timedDur') return fmsParseHours(v) !== null; | |
| if (name === 'alarmTime' || name === 'snoozeMorning' || name === 'snoozeEvening') { | |
| return fmsParseClock(v) !== null; | |
| } | |
| if (name === 'snoozeLead') return fmsParseOffset(v) !== null; | |
| return v === '' || fmsParseOffset(v) !== null; | |
| } | |
| function fmsReadSetting(name) { | |
| let v; | |
| try { v = localStorage.getItem(FMS_SET_KEYS[name]); } | |
| catch { v = null; } | |
| if (v === null || !fmsValidSetting(name, v)) return FMS_SET_DEFAULTS[name]; | |
| return v; | |
| } | |
| function fmsWriteSetting(name, value) { | |
| try { localStorage.setItem(FMS_SET_KEYS[name], value); } | |
| catch { /* storage unavailable; the setting just won't persist */ } | |
| } | |
| function fmsAlertsEnabled() { | |
| return fmsReadSetting('alertsOn') !== '0'; | |
| } | |
| function fmsSnoozeEnabled() { | |
| return fmsReadSetting('snoozeOn') !== '0'; | |
| } | |
| function fmsViewInNewTab() { | |
| return fmsReadSetting('viewNewTab') !== '0'; | |
| } | |
| // Raw user text for the alert offset; '' means no alert. | |
| function fmsAlertText(allDay) { | |
| if (!fmsAlertsEnabled()) return ''; | |
| return fmsReadSetting(allDay ? 'alertAllDay' : 'alertTimed'); | |
| } | |
| function fmsAlarmMinutes() { | |
| return fmsParseClock(fmsReadSetting('alarmTime')) || FMS_DEFAULT_ALARM_MINUTES; | |
| } | |
| /** | |
| * @returns {string} How to read slash dates: `MDY` or `DMY`. | |
| */ | |
| function fmsDateOrder() { | |
| return fmsReadSetting('dateOrder'); | |
| } | |
| // ISO offset for JMAP, or '' when no alert applies. All-day events start at | |
| // midnight, so their offset is shifted to fire at the configured clock time. | |
| function fmsAlertOffset(allDay) { | |
| const minutes = fmsParseOffset(fmsAlertText(allDay)); | |
| if (!minutes) return ''; | |
| if (!allDay) return '-' + fmsMinutesToIso(minutes); | |
| const eff = minutes - fmsAlarmMinutes(); | |
| return eff > 0 ? '-' + fmsMinutesToIso(eff) : fmsMinutesToIso(-eff); | |
| } | |
| // JSCalendar alerts map for a new event, or null when alerts are off. | |
| // Only the trigger is set; Fastmail decides how the alert is delivered. | |
| function fmsAlertsFor(allDay) { | |
| const offset = fmsAlertOffset(allDay); | |
| if (!offset) return null; | |
| return { a1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset } } }; | |
| } | |
| // ISO duration for timed events from the configured hours. | |
| function fmsTimedDurationIso() { | |
| return fmsMinutesToIso(fmsParseHours(fmsReadSetting('timedDur')) || 60); | |
| } | |
| // Human text for the preview modal's Alert line, e.g. "1D before at 8:00". | |
| function fmsAlertLine(event) { | |
| if (!event.alerts) return 'none'; | |
| const allDay = !!event.showWithoutTime; | |
| const base = fmsAlertText(allDay) + ' before'; | |
| return allDay ? base + ' at ' + fmsReadSetting('alarmTime') : base; | |
| } | |
| // Created-event memory so a re-rendered banner still shows Added/View. | |
| const FMS_CREATED_KEY = 'fms.createdEvents'; | |
| function fmsReadCreatedMap() { | |
| try { return JSON.parse(localStorage.getItem(FMS_CREATED_KEY)) || {}; } | |
| catch { return {}; } | |
| } | |
| function fmsRememberCreated(key, eventId) { | |
| const map = fmsReadCreatedMap(); | |
| map[key] = eventId; | |
| const keys = Object.keys(map); | |
| for (let i = 0; i < keys.length - FMS_CREATED_MAX; i++) delete map[keys[i]]; | |
| try { localStorage.setItem(FMS_CREATED_KEY, JSON.stringify(map)); } | |
| catch { /* storage unavailable; the memory just won't persist */ } | |
| } | |
| function fmsCreatedId(key) { | |
| return fmsReadCreatedMap()[key] || null; | |
| } | |
| function fmsForgetCreated(key) { | |
| const map = fmsReadCreatedMap(); | |
| delete map[key]; | |
| try { localStorage.setItem(FMS_CREATED_KEY, JSON.stringify(map)); } | |
| catch { /* storage unavailable */ } | |
| } | |
| function fmsCheckboxRow(labelText, checked, onchange) { | |
| const row = document.createElement('label'); | |
| row.className = 'fms-set-row'; | |
| const span = document.createElement('span'); | |
| span.textContent = labelText; | |
| const box = document.createElement('input'); | |
| box.type = 'checkbox'; | |
| box.checked = checked; | |
| box.addEventListener('change', () => onchange(box.checked)); | |
| row.append(span, box); | |
| return row; | |
| } | |
| // Valid entries persist immediately; invalid ones only mark the field. | |
| function fmsInputRow(labelText, name, suffix, allowEmpty, parse) { | |
| const row = document.createElement('label'); | |
| row.className = 'fms-set-row'; | |
| const span = document.createElement('span'); | |
| span.textContent = labelText; | |
| const input = document.createElement('input'); | |
| input.type = 'text'; | |
| input.className = 'fms-input'; | |
| input.value = fmsReadSetting(name); | |
| input.oninput = () => { | |
| const v = input.value.trim(); | |
| const ok = (allowEmpty && v === '') || parse(v) !== null; | |
| input.classList.toggle('fms-invalid', !ok); | |
| input.classList.toggle('fms-valid', ok && v !== ''); | |
| if (ok) fmsWriteSetting(name, v); | |
| }; | |
| const unit = document.createElement('span'); | |
| unit.className = 'fms-unit'; | |
| unit.textContent = suffix; | |
| const wrap = document.createElement('span'); | |
| wrap.className = 'fms-input-wrap'; | |
| wrap.append(input, unit); | |
| row.append(span, wrap); | |
| return row; | |
| } | |
| /** | |
| * A labelled dropdown row that persists on change. | |
| * | |
| * @param {string} labelText Row label. | |
| * @param {string} name Setting key. | |
| * @param {Array<[string, string]>} options [value, label] pairs. | |
| * @returns {HTMLLabelElement} The row. | |
| */ | |
| function fmsSelectRow(labelText, name, options) { | |
| const row = document.createElement('label'); | |
| row.className = 'fms-set-row'; | |
| const span = document.createElement('span'); | |
| span.textContent = labelText; | |
| const select = document.createElement('select'); | |
| select.className = 'fms-select fms-set-select'; | |
| for (const [value, text] of options) select.append(new Option(text, value)); | |
| select.value = fmsReadSetting(name); | |
| select.addEventListener('change', () => fmsWriteSetting(name, select.value)); | |
| row.append(span, select); | |
| return row; | |
| } | |
| function fmsSectionHead(text) { | |
| const head = document.createElement('div'); | |
| head.className = 'fms-settings-head'; | |
| head.textContent = text; | |
| return head; | |
| } | |
| function fmsHr() { | |
| const hr = document.createElement('hr'); | |
| hr.className = 'fms-hr'; | |
| return hr; | |
| } | |
| function fmsLegend() { | |
| const div = document.createElement('div'); | |
| div.className = 'fms-legend'; | |
| div.textContent = 'Times are a number plus a unit: M minutes, H hours, D days, ' + | |
| 'W weeks, MO months. Fractions work (0.25H, 1.5D). Duration is plain hours. ' + | |
| 'Leave an alert blank for none. Alert time is a 24-hour clock (8:00) and ' + | |
| 'sets when all-day alerts fire. Slash dates are ambiguous, so pick the ' + | |
| 'reading your correspondents use. Snooze suggestions read the open message ' | |
| + 'and add rows to Fastmail\'s own snooze menu, grouped under each date ' | |
| + 'found: the morning of that date, the days either side of it, and the ' | |
| + 'deadline lead time before it. Morning at sets where all of those land.'; | |
| return div; | |
| } | |
| /** | |
| * The legend, behind a disclosure so the panel opens short. | |
| * | |
| * @returns {HTMLDetailsElement} A collapsed section holding the legend. | |
| */ | |
| function fmsHelpDetails() { | |
| const details = document.createElement('details'); | |
| details.className = 'fms-help'; | |
| const summary = document.createElement('summary'); | |
| summary.textContent = 'Help / Details'; | |
| details.append(summary, fmsLegend()); | |
| return details; | |
| } | |
| // Comments on the public gist are the feedback channel. Kept in sync with | |
| // GISTS in scripts/build.py. | |
| const FMS_FEEDBACK_URL = | |
| 'https://gist.github.com/e-volusian/59acd6269c3738e2349cd14338a9069a'; | |
| function fmsFeedbackLink() { | |
| const a = document.createElement('a'); | |
| a.className = 'fms-feedback'; | |
| a.href = FMS_FEEDBACK_URL; | |
| a.target = '_blank'; | |
| a.rel = 'noopener noreferrer'; | |
| a.textContent = 'Feedback? Leave a gist comment'; | |
| return a; | |
| } | |
| function fmsSettingsPanel() { | |
| const panel = document.createElement('div'); | |
| panel.className = 'fms-settings'; | |
| panel.append( | |
| fmsSectionHead('Settings'), fmsHr(), | |
| fmsCheckboxRow('Test mode', fmsTestModeEnabled(), fmsSetTestMode), | |
| fmsCheckboxRow('Open View in new tab', fmsViewInNewTab(), | |
| on => fmsWriteSetting('viewNewTab', on ? '1' : '0')), | |
| fmsInputRow('Timed event duration', 'timedDur', 'hours', false, fmsParseHours), | |
| fmsSelectRow('Read 10/11 as', 'dateOrder', [ | |
| [FMS_DATE_ORDER_MDY, 'Oct 11 (M/D)'], | |
| [FMS_DATE_ORDER_DMY, '10 Nov (D/M)'], | |
| ]), | |
| fmsSectionHead('Snooze'), fmsHr(), | |
| fmsCheckboxRow('Suggest snooze times', fmsSnoozeEnabled(), | |
| on => fmsWriteSetting('snoozeOn', on ? '1' : '0')), | |
| fmsInputRow('Morning at', 'snoozeMorning', '24h', false, fmsParseClock), | |
| fmsInputRow('Deadline lead time', 'snoozeLead', 'before', false, fmsParseOffset), | |
| fmsSectionHead('Reminder'), fmsHr(), | |
| fmsCheckboxRow('Alerts', fmsAlertsEnabled(), | |
| on => fmsWriteSetting('alertsOn', on ? '1' : '0')), | |
| fmsInputRow('All-day alert', 'alertAllDay', 'before', true, fmsParseOffset), | |
| fmsInputRow('Timed alert', 'alertTimed', 'before', true, fmsParseOffset), | |
| fmsInputRow('All-day alert time', 'alarmTime', '24h', false, fmsParseClock), | |
| fmsHr(), fmsHelpDetails(), fmsFeedbackLink()); | |
| return panel; | |
| } | |
| const FMS_GEAR_SVG = | |
| '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' + | |
| '<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 ' + | |
| '0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36' + | |
| '-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62' + | |
| '.94l-2.39-.96a.488.488 0 0 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09' + | |
| '.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39' + | |
| '-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c' + | |
| '.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32a.49.49 0 0 0-.12-.61l-2.01' + | |
| '-1.58zM12 15.6a3.6 3.6 0 1 1 0-7.2 3.6 3.6 0 0 1 0 7.2z"/></svg>'; | |
| // The gear stays visibly "pressed" while the panel is open: same button closes it. | |
| function fmsSettingsButton() { | |
| const wrap = document.createElement('span'); | |
| wrap.className = 'fms-settings-wrap'; | |
| const btn = document.createElement('button'); | |
| btn.type = 'button'; | |
| btn.className = 'fms-gear'; | |
| btn.title = 'Settings'; | |
| btn.setAttribute('aria-label', 'Settings'); | |
| btn.setAttribute('aria-expanded', 'false'); | |
| btn.innerHTML = FMS_GEAR_SVG; | |
| let panel = null; | |
| btn.addEventListener('click', () => { | |
| if (panel) { panel.remove(); panel = null; } | |
| else { panel = fmsSettingsPanel(); wrap.append(panel); } | |
| btn.classList.toggle('is-open', !!panel); | |
| btn.title = panel ? 'Click again to close settings' : 'Settings'; | |
| btn.setAttribute('aria-expanded', String(!!panel)); | |
| }); | |
| wrap.append(btn); | |
| return wrap; | |
| } | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = { | |
| fmsParseOffset, fmsParseHours, fmsParseClock, fmsMinutesToIso, | |
| fmsReadSetting, fmsWriteSetting, fmsAlertsEnabled, fmsViewInNewTab, fmsDateOrder, | |
| fmsSnoozeEnabled, | |
| fmsAlertOffset, fmsAlertsFor, fmsTimedDurationIso, fmsAlertLine, | |
| fmsRememberCreated, fmsCreatedId, fmsForgetCreated, | |
| }; | |
| } | |
| const FMS_CSS = ` | |
| .fms-banner { | |
| display: flex; flex-direction: column; gap: 6px; | |
| margin: 8px 16px 10px; padding: 10px 14px; | |
| border: 1px solid #c9d4e8; border-radius: 10px; | |
| background: #eef3fc; color: #1a2b49; font-size: 13px; | |
| } | |
| .fms-row { display: flex; align-items: center; gap: 10px; flex-wrap: nowrap; } | |
| .fms-cal { flex: 0 0 auto; display: inline-flex; } | |
| .fms-cal svg { width: 34px; height: 34px; display: block; } | |
| .fms-cal-body { fill: #fff; stroke: #b9c8e4; } | |
| .fms-cal-day { fill: #1a2b49; } | |
| .fms-date-label { font-weight: 600; flex: 1 1 auto; min-width: 0; } | |
| /* A date heading inside Fastmail's snooze menu, on the menu's own row height | |
| so the whole list keeps one rhythm. Qualified with li.v-MenuOption because | |
| Fastmail sets "padding: 0 4px" on that class: a bare .fms-snooze-date ties | |
| on specificity and loses, squeezing the row down to its icon. */ | |
| li.v-MenuOption.fms-snooze-date { | |
| position: relative; | |
| display: flex; align-items: center; gap: 7px; | |
| padding: 9px 4px 5px; font-weight: 600; | |
| } | |
| /* Fastmail draws its section divider as a block ::after. In a flex row that | |
| becomes a flex item sitting after the caret and eating its margins, which | |
| knocks the caret out of line with the row above. Take it out of flow; it | |
| still draws across the bottom of the row. */ | |
| li.v-MenuOption.fms-snooze-date.v-MenuOption--lastOfSection::after { | |
| position: absolute; left: 0; right: 0; bottom: 0; margin: 0 12px; | |
| } | |
| .fms-snooze-date .fms-cal { flex: 0 0 auto; } | |
| .fms-snooze-date .fms-cal svg { width: 17px; height: 17px; } | |
| /* An expander is a row you click, so it takes the full row height. */ | |
| li.v-MenuOption.fms-snooze-more { cursor: pointer; padding: 7px 4px; } | |
| /* A transparent overlay, so the whole row is the target without wrapping the | |
| icon and label in a control. */ | |
| .fms-snooze-hit { | |
| position: absolute; inset: 0; width: 100%; | |
| border: 0; background: none; padding: 0; cursor: pointer; | |
| } | |
| .fms-snooze-hit:hover { background: currentColor; opacity: 0.06; } | |
| .fms-snooze-caret { | |
| margin-left: auto; flex: 0 0 auto; | |
| border-top: 5px solid currentColor; | |
| border-left: 4px solid transparent; border-right: 4px solid transparent; | |
| opacity: 0.6; transition: transform 0.15s ease; | |
| } | |
| .fms-snooze-more.is-open .fms-snooze-caret { transform: rotate(180deg); } | |
| .fms-select.fms-dates { flex: 1 1 auto; min-width: 0; width: auto; } | |
| .fms-badge { | |
| min-height: 24px; padding: 3px 10px; | |
| display: inline-flex; align-items: center; justify-content: center; | |
| border-radius: 999px; background: #ffdf70; color: #574400; | |
| font-size: 11px; font-weight: 600; letter-spacing: 0.02em; | |
| text-transform: uppercase; text-align: center; line-height: 1.15; | |
| max-width: 90px; flex: 0 0 auto; | |
| } | |
| .fms-actions { display: flex; gap: 0; align-items: center; flex: 0 0 auto; } | |
| .fms-btn { | |
| height: 32px; padding: 0 14px; border: 1px solid #2f5bb7; | |
| border-radius: 8px; background: #2f5bb7; color: #fff; cursor: pointer; | |
| font-size: 13px; font-weight: 500; white-space: nowrap; | |
| display: inline-flex; align-items: center; justify-content: center; | |
| transition: background 0.15s ease; | |
| } | |
| .fms-btn:hover:not(:disabled) { background: #264d9f; } | |
| .fms-btn--ghost { background: none; color: #2f5bb7; } | |
| .fms-btn--ghost:hover:not(:disabled) { background: rgba(47, 91, 183, 0.08); } | |
| .fms-btn:disabled { opacity: 0.55; cursor: default; } | |
| .fms-apply { gap: 8px; font-weight: 600; } | |
| .fms-apply .fms-arrow { font-size: 16px; line-height: 1; } | |
| .fms-gear, .fms-close { | |
| width: 36px; height: 36px; | |
| display: inline-flex; align-items: center; justify-content: center; | |
| background: none; border: none; border-radius: 10px; | |
| color: #55658a; cursor: pointer; font-size: 22px; line-height: 1; | |
| transition: background 0.15s ease, color 0.15s ease; | |
| } | |
| .fms-gear svg { width: 32px; height: 32px; display: block; transition: transform 0.2s ease; } | |
| .fms-gear:hover, .fms-close:hover { background: rgba(47, 91, 183, 0.1); color: #1a2b49; } | |
| .fms-gear.is-open { background: #2f5bb7; color: #fff; } | |
| .fms-gear.is-open:hover { background: #264d9f; color: #fff; } | |
| .fms-gear.is-open svg { transform: rotate(45deg); } | |
| .fms-select { | |
| height: 32px; padding: 0 30px 0 12px; font-size: 13px; color: #1a2b49; | |
| border: 1px solid #b9c8e4; border-radius: 8px; background-color: #fff; | |
| cursor: pointer; -webkit-appearance: none; appearance: none; | |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%2355658a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); | |
| background-repeat: no-repeat; background-position: right 10px center; | |
| transition: border-color 0.15s ease; | |
| } | |
| .fms-select:hover { border-color: #2f5bb7; } | |
| .fms-select:focus-visible, .fms-btn:focus-visible, | |
| .fms-gear:focus-visible, .fms-close:focus-visible { | |
| outline: 2px solid #2f5bb7; outline-offset: 1px; | |
| } | |
| .fms-settings-wrap { position: relative; display: inline-flex; } | |
| .fms-settings { | |
| position: absolute; top: 40px; right: 0; z-index: 2147483000; | |
| display: flex; flex-direction: column; gap: 8px; width: 250px; | |
| padding: 12px 14px; border: 1px solid #c9d4e8; border-radius: 10px; | |
| background: #fff; color: #1a2b49; | |
| box-shadow: 0 6px 20px rgba(15, 23, 42, 0.2); | |
| } | |
| .fms-settings-head { font-weight: 600; font-size: 12px; } | |
| .fms-set-select { height: 28px; font-size: 12px; padding: 0 24px 0 8px; } | |
| .fms-hr { border: none; border-top: 1px solid #dfe6f2; margin: 0; } | |
| .fms-set-row { | |
| display: flex; align-items: center; justify-content: space-between; | |
| gap: 8px; font-size: 12px; cursor: pointer; | |
| } | |
| .fms-input-wrap { display: inline-flex; align-items: center; gap: 6px; } | |
| .fms-input { | |
| height: 28px; width: 60px; padding: 0 8px; font-size: 12px; color: #1a2b49; | |
| border: 1px solid #b9c8e4; border-radius: 8px; background: #fff; | |
| text-align: right; transition: border-color 0.15s ease, background 0.15s ease; | |
| } | |
| .fms-input:focus-visible { outline: 2px solid #2f5bb7; outline-offset: 1px; } | |
| .fms-input.fms-valid { border-color: #2e8b57; } | |
| .fms-input.fms-invalid { border-color: #c0392b; background: #fdecea; } | |
| .fms-unit { font-size: 12px; color: #55658a; } | |
| .fms-legend { font-size: 11px; color: #55658a; line-height: 1.5; } | |
| /* The legend, folded away behind a disclosure. The triangle is drawn rather | |
| than left to the browser so the summary reads as expandable everywhere. */ | |
| .fms-help > summary { | |
| display: flex; align-items: center; gap: 6px; | |
| padding: 2px 0; cursor: pointer; user-select: none; | |
| font-size: 11px; font-weight: 600; color: #2f5bb7; list-style: none; | |
| } | |
| .fms-help > summary::-webkit-details-marker { display: none; } | |
| .fms-help > summary::before { | |
| content: ''; flex: 0 0 auto; | |
| border-left: 5px solid currentColor; | |
| border-top: 4px solid transparent; border-bottom: 4px solid transparent; | |
| transition: transform 0.15s ease; | |
| } | |
| .fms-help[open] > summary::before { transform: rotate(90deg); } | |
| .fms-help > summary:hover { text-decoration: underline; } | |
| .fms-help > .fms-legend { margin-top: 5px; } | |
| .fms-feedback { font-size: 11px; color: #2f5bb7; text-decoration: none; } | |
| .fms-feedback:hover { text-decoration: underline; } | |
| .fms-modal-overlay { | |
| position: fixed; inset: 0; z-index: 2147483000; | |
| background: rgba(15, 23, 42, 0.45); | |
| display: flex; align-items: center; justify-content: center; | |
| } | |
| .fms-modal { | |
| background: #fff; color: #1a2b49; border: 1px solid #c9d4e8; | |
| border-radius: 10px; padding: 16px 20px; width: 90%; max-width: 420px; | |
| max-height: 80vh; overflow: auto; font-size: 13px; | |
| box-shadow: 0 8px 30px rgba(15, 23, 42, 0.3); | |
| } | |
| .fms-modal h3 { margin: 0 0 10px; font-size: 14px; } | |
| .fms-modal-fields { | |
| display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; margin: 0 0 12px; | |
| } | |
| .fms-modal-fields dt { font-weight: 600; } | |
| .fms-modal-fields dd { margin: 0; overflow-wrap: anywhere; } | |
| @media (prefers-color-scheme: dark) { | |
| .fms-banner { background: #1e2738; border-color: #38445c; color: #dbe4f5; } | |
| .fms-cal-body { fill: #2a3550; stroke: #38445c; } | |
| .fms-cal-day { fill: #dbe4f5; } | |
| .fms-badge { background: #d9b23c; color: #241d00; } | |
| .fms-btn--ghost { color: #9db8e8; border-color: #4a68a8; } | |
| .fms-btn--ghost:hover:not(:disabled) { background: rgba(157, 184, 232, 0.12); } | |
| .fms-gear, .fms-close { color: #94a3c4; } | |
| .fms-gear:hover, .fms-close:hover { background: rgba(157, 184, 232, 0.12); color: #dbe4f5; } | |
| .fms-gear.is-open, .fms-gear.is-open:hover { background: #9db8e8; color: #1e2738; } | |
| .fms-hr { border-top-color: #38445c; } | |
| .fms-input { background: #2a3550; border-color: #38445c; color: #dbe4f5; } | |
| .fms-input.fms-invalid { background: #472a2e; border-color: #d9756b; } | |
| .fms-input.fms-valid { border-color: #58b58b; } | |
| .fms-unit, .fms-legend { color: #94a3c4; } | |
| .fms-feedback, .fms-help > summary { color: #9db8e8; } | |
| .fms-settings { background: #1e2738; border-color: #38445c; color: #dbe4f5; } | |
| .fms-select { | |
| background-color: #2a3550; border-color: #38445c; color: #dbe4f5; | |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%2394a3c4' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); | |
| } | |
| .fms-select:hover { border-color: #9db8e8; } | |
| .fms-modal { background: #1e2738; border-color: #38445c; color: #dbe4f5; } | |
| } | |
| `; | |
| /** | |
| * Inject the banner stylesheet, once. | |
| * | |
| * Side effect: appends a `<style id="fms-styles">` to `document.head`. | |
| */ | |
| function fmsInjectStyles() { | |
| if (document.querySelector('#fms-styles')) return; | |
| const style = document.createElement('style'); | |
| style.id = 'fms-styles'; | |
| style.textContent = FMS_CSS; | |
| document.head.appendChild(style); | |
| } | |
| /** | |
| * @param {{hours: number, minutes: number}} time Clock time. | |
| * @returns {string} The time in the viewer's locale format. | |
| */ | |
| function fmsFormatTime(time) { | |
| return new Date(2000, 0, 1, time.hours, time.minutes) | |
| .toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); | |
| } | |
| /** | |
| * Dropdown text: only the resolved date, never the source phrase -- the phrase | |
| * is exposed as a tooltip instead, so a misread date can still be spotted. | |
| * | |
| * @param {object} hit A detected hit. | |
| * @returns {string} Human-readable date, plus time when the hit has one. | |
| */ | |
| function fmsWhenText(hit) { | |
| return fmsFormatDate(hit.date) + (hit.time ? ', ' + fmsFormatTime(hit.time) : ''); | |
| } | |
| /** | |
| * @param {object} hit A detected hit. | |
| * @returns {string} Tooltip text naming the phrase the date came from. | |
| */ | |
| function fmsWhyText(hit) { | |
| return 'Matched "' + hit.phrase + '" in this message'; | |
| } | |
| /** | |
| * @param {object} hit A detected hit. | |
| * @param {string} subject The message subject, used as the event title. | |
| * @param {string} messageKey Identity from `fmsMessageKey`. | |
| * @returns {object} An event spec for `fmsBuildEvent`. | |
| */ | |
| function fmsAddSpec(hit, subject, messageKey) { | |
| return { | |
| title: subject, | |
| description: 'Created from email "' + subject + '" (matched "' + hit.phrase + | |
| '")\n\n' + location.href, | |
| url: location.href, | |
| uid: fmsHitUid(hit, messageKey), | |
| date: hit.date, time: hit.time, | |
| }; | |
| } | |
| /** | |
| * Deep-links to the created event; the `#fms-edit` marker makes the script on | |
| * the calendar page open the event's Edit dialog directly. | |
| * | |
| * @param {Date} date The event's day. | |
| * @param {string} eventId The created event's id. | |
| * @returns {HTMLButtonElement} The View button. | |
| */ | |
| function fmsViewButton(date, eventId) { | |
| const btn = document.createElement('button'); | |
| btn.type = 'button'; | |
| btn.className = 'fms-btn fms-btn--ghost fms-view'; | |
| btn.textContent = 'View'; | |
| btn.title = 'Open in Calendar'; | |
| btn.addEventListener('click', () => { | |
| const url = fmsCalendarDayUrl(location, date, eventId) + '#fms-edit'; | |
| // window.name survives the SPA's URL rewriting; the hash is a fallback. | |
| if (fmsViewInNewTab()) window.open(url, 'fms-edit'); | |
| else { window.name = 'fms-edit'; location.href = url; } | |
| }); | |
| return btn; | |
| } | |
| /** | |
| * A stable key for "this message already produced this event". | |
| * | |
| * @param {object} hit A detected hit. | |
| * @param {string} messageKey Identity from `fmsMessageKey`. | |
| * @returns {string} The lookup key for the created-event memory. | |
| */ | |
| function fmsHitKey(hit, messageKey) { | |
| return messageKey + '|' + fmsToLocalIso(hit.date) + | |
| (hit.time ? '@' + hit.time.hours + ':' + hit.time.minutes : ''); | |
| } | |
| /** | |
| * The marker uid stamped on events we create, from the same identity as | |
| * {@link fmsHitKey}. Changing this derivation orphans every event already | |
| * created with the old form — they would all show "Add" again. | |
| * | |
| * @param {object} hit A detected hit. | |
| * @param {string} messageKey Identity from `fmsMessageKey`. | |
| * @returns {string} A JSCalendar uid; kept to iCalendar-safe characters. | |
| */ | |
| function fmsHitUid(hit, messageKey) { | |
| return 'fms-' + fmsHitKey(hit, messageKey).replace(/[^A-Za-z0-9@:.|-]/g, '_'); | |
| } | |
| /** | |
| * Draw the Add/Added state for a known event id (or its absence). | |
| * | |
| * @param {HTMLButtonElement} btn The Add button. | |
| * @param {object} hit The currently selected hit. | |
| * @param {?string} id The created event's id, or null for none. | |
| */ | |
| function fmsPaintState(btn, hit, id) { | |
| const old = btn.parentElement && btn.parentElement.querySelector('.fms-view'); | |
| if (old) old.remove(); | |
| if (!id) { fmsResetApply(btn); return; } | |
| btn.disabled = true; | |
| fmsApplyLabel(btn, 'Added ✓', false); | |
| btn.title = 'Already added'; | |
| btn.after(fmsViewButton(hit.date, id)); | |
| } | |
| /** | |
| * Reflect whether the selected date already has a created event. | |
| * | |
| * The per-browser memory paints immediately; the calendar is the source of | |
| * truth, so the day is then queried for our marker uid and the state (and the | |
| * memory) corrected to match. A failed lookup keeps the painted state. | |
| * | |
| * @param {HTMLButtonElement} btn The Add button. | |
| * @param {object} hit The currently selected hit. | |
| * @param {string} messageKey Identity from `fmsMessageKey`. | |
| */ | |
| function fmsApplyState(btn, hit, messageKey) { | |
| const key = fmsHitKey(hit, messageKey); | |
| btn.dataset.fmsKey = key; | |
| fmsPaintState(btn, hit, fmsCreatedId(key)); | |
| fmsFindEventByUid(fmsHitUid(hit, messageKey), hit.date).then(id => { | |
| // The user may have switched dates while the query ran. | |
| if (btn.dataset.fmsKey !== key) return; | |
| if (id) fmsRememberCreated(key, id); | |
| else fmsForgetCreated(key); | |
| fmsPaintState(btn, hit, id); | |
| }).catch(() => {}); | |
| } | |
| /** | |
| * @param {Array<object>} hits Detected hits, in date order. | |
| * @returns {HTMLSelectElement} A labelled picker; each option carries the | |
| * phrase it came from as a tooltip. | |
| */ | |
| function fmsDateSelect(hits) { | |
| const sel = document.createElement('select'); | |
| sel.className = 'fms-select fms-dates'; | |
| sel.setAttribute('aria-label', 'Detected date'); | |
| hits.forEach((h, i) => { | |
| const opt = new Option(fmsWhenText(h), String(i)); | |
| opt.title = fmsWhyText(h); | |
| sel.append(opt); | |
| }); | |
| return sel; | |
| } | |
| /** | |
| * @param {HTMLButtonElement} btn Button to relabel. | |
| * @param {string} text Visible label. | |
| * @param {boolean} arrow Append the decorative arrow. | |
| */ | |
| function fmsApplyLabel(btn, text, arrow) { | |
| btn.replaceChildren(document.createTextNode(text)); | |
| if (arrow) { | |
| const span = document.createElement('span'); | |
| span.className = 'fms-arrow'; | |
| span.setAttribute('aria-hidden', 'true'); | |
| span.textContent = '→'; | |
| btn.append(span); | |
| } | |
| } | |
| /** @param {HTMLButtonElement} btn Button to return to its unused state. */ | |
| function fmsResetApply(btn) { | |
| btn.disabled = false; | |
| fmsApplyLabel(btn, 'Add', true); | |
| btn.title = 'Add to calendar'; | |
| } | |
| /** | |
| * Create the event and reflect the result on the button. | |
| * | |
| * @param {HTMLButtonElement} btn The Add button. | |
| * @param {object} spec Event spec. | |
| * @param {string} key Created-event memory key. | |
| * @returns {Promise<void>} Resolves once the button reflects the outcome. | |
| */ | |
| async function fmsApplyCreate(btn, spec, key) { | |
| btn.disabled = true; | |
| fmsApplyLabel(btn, '…', false); | |
| const old = btn.parentElement.querySelector('.fms-view'); | |
| if (old) old.remove(); | |
| try { | |
| const id = await fmsCreateEvent(spec); | |
| fmsRememberCreated(key, id); | |
| fmsApplyLabel(btn, 'Added ✓', false); | |
| btn.title = 'Added'; | |
| btn.after(fmsViewButton(spec.date, id)); | |
| } catch (e) { | |
| fmsResetApply(btn); | |
| btn.title = 'Failed — retry'; | |
| btn.setAttribute('aria-label', 'Add to calendar, previous attempt failed'); | |
| console.info('[fms] add failed:', e.message); | |
| } | |
| } | |
| // Test mode: build the event and show it in a modal; nothing is created. | |
| /** | |
| * @param {HTMLButtonElement} btn The Add button. | |
| * @param {function(): object} makeEvent Builds the spec to preview. | |
| * @returns {Promise<void>} | |
| */ | |
| async function fmsPreviewAction(btn, makeEvent) { | |
| btn.disabled = true; | |
| fmsApplyLabel(btn, '…', false); | |
| try { | |
| const { event, calendarName } = await fmsPreviewEvent(makeEvent()); | |
| document.body.appendChild(fmsPreviewModal(event, calendarName)); | |
| } catch (e) { | |
| console.info('[fms] preview failed:', e.message); | |
| } | |
| fmsResetApply(btn); | |
| } | |
| /** | |
| * @param {function(): object} getHit Reads the currently selected hit. | |
| * @param {string} subject Message subject, used as the event title. | |
| * @param {string} messageKey Identity from `fmsMessageKey`. | |
| * @returns {HTMLButtonElement} The Add button. | |
| */ | |
| function fmsApplyButton(getHit, subject, messageKey) { | |
| const btn = document.createElement('button'); | |
| btn.type = 'button'; | |
| btn.className = 'fms-btn fms-apply'; | |
| fmsResetApply(btn); | |
| btn.addEventListener('click', () => { | |
| const hit = getHit(); | |
| const spec = fmsAddSpec(hit, subject, messageKey); | |
| if (fmsTestModeEnabled()) fmsPreviewAction(btn, () => spec); | |
| else fmsApplyCreate(btn, spec, fmsHitKey(hit, messageKey)); | |
| }); | |
| return btn; | |
| } | |
| /** | |
| * @param {object} event The built JSCalendar event. | |
| * @param {string} calendarName Target calendar's name. | |
| * @returns {HTMLDListElement} The preview field list. | |
| */ | |
| function fmsModalFields(event, calendarName) { | |
| const dl = document.createElement('dl'); | |
| dl.className = 'fms-modal-fields'; | |
| for (const [name, value] of fmsPreviewLines(event, calendarName)) { | |
| const dt = document.createElement('dt'); | |
| dt.textContent = name; | |
| const dd = document.createElement('dd'); | |
| dd.textContent = value; | |
| dl.append(dt, dd); | |
| } | |
| return dl; | |
| } | |
| /** | |
| * Build the test-mode preview dialog. | |
| * | |
| * Focus moves into the dialog on open and returns to the trigger on close; | |
| * Escape and a backdrop click both dismiss it. | |
| * | |
| * @param {object} event The built JSCalendar event. | |
| * @param {string} calendarName Target calendar's name. | |
| * @returns {HTMLElement} The overlay, ready to append. | |
| */ | |
| function fmsPreviewModal(event, calendarName) { | |
| const previousFocus = document.activeElement; | |
| const overlay = document.createElement('div'); | |
| overlay.className = 'fms-modal-overlay'; | |
| const box = document.createElement('div'); | |
| box.className = 'fms-modal'; | |
| box.setAttribute('role', 'dialog'); | |
| box.setAttribute('aria-modal', 'true'); | |
| box.setAttribute('aria-label', 'Test mode preview'); | |
| const head = document.createElement('h3'); | |
| head.textContent = 'Test mode — would have created:'; | |
| const close = document.createElement('button'); | |
| close.type = 'button'; | |
| close.className = 'fms-btn'; | |
| close.textContent = 'Close'; | |
| const dismiss = () => { | |
| overlay.remove(); | |
| if (previousFocus && previousFocus.isConnected) previousFocus.focus(); | |
| }; | |
| close.addEventListener('click', dismiss); | |
| overlay.addEventListener('click', e => { if (e.target === overlay) dismiss(); }); | |
| overlay.addEventListener('keydown', e => { | |
| if (e.key === 'Escape') { e.stopPropagation(); dismiss(); } | |
| // Only one control, so trapping Tab means keeping focus on it. | |
| else if (e.key === 'Tab') { e.preventDefault(); close.focus(); } | |
| }); | |
| box.append(head, fmsModalFields(event, calendarName), close); | |
| overlay.append(box); | |
| // Deferred so the caller can append before focus moves. | |
| setTimeout(() => close.focus(), 0); | |
| return overlay; | |
| } | |
| /** @returns {HTMLElement} The settings gear and dismiss button. */ | |
| function fmsRightControls() { | |
| const actions = document.createElement('span'); | |
| actions.className = 'fms-actions'; | |
| const close = document.createElement('button'); | |
| close.type = 'button'; | |
| close.className = 'fms-close'; | |
| close.title = 'Dismiss'; | |
| close.setAttribute('aria-label', 'Dismiss this suggestion'); | |
| close.textContent = '✕'; | |
| close.addEventListener('click', () => close.closest('.fms-banner').remove()); | |
| actions.append(fmsSettingsButton(), close); | |
| return actions; | |
| } | |
| /** | |
| * A little tear-off calendar showing the target date itself. | |
| * | |
| * @param {Date} date Date to draw. | |
| * @returns {HTMLElement} A decorative, aria-hidden icon. | |
| */ | |
| function fmsCalIcon(date) { | |
| const month = date.toLocaleDateString(undefined, { month: 'short' }) | |
| .toUpperCase().replace(/[<&]/g, ''); | |
| const span = document.createElement('span'); | |
| span.className = 'fms-cal'; | |
| span.innerHTML = | |
| '<svg viewBox="0 0 32 32" aria-hidden="true">' + | |
| '<rect x="1" y="3" width="30" height="28" rx="6" class="fms-cal-body" stroke-width="1.5"/>' + | |
| '<path d="M1 13 V9 q0-6 6-6 h18 q6 0 6 6 v4 z" fill="#d5453c"/>' + | |
| '<text x="16" y="10.5" text-anchor="middle" font-size="7.5" font-weight="700" ' + | |
| 'fill="#fff" font-family="system-ui, sans-serif" letter-spacing="0.5">' + month + '</text>' + | |
| '<text x="16" y="27" text-anchor="middle" font-size="13.5" font-weight="700" ' + | |
| 'class="fms-cal-day" font-family="system-ui, sans-serif">' + date.getDate() + '</text>' + | |
| '</svg>'; | |
| return span; | |
| } | |
| /** | |
| * @param {object} hit A detected hit. | |
| * @returns {HTMLElement} A badge reading "Timed event" or "All day". | |
| */ | |
| function fmsTypeBadge(hit) { | |
| const badge = document.createElement('span'); | |
| badge.className = 'fms-badge'; | |
| badge.textContent = hit.time ? 'Timed event' : 'All day'; | |
| return badge; | |
| } | |
| /** | |
| * A dropdown only when there is actually something to choose between. | |
| * | |
| * @param {Array<object>} hits Detected hits. | |
| * @returns {HTMLElement} A `<select>`, or a label for a single hit. | |
| */ | |
| function fmsDateControl(hits) { | |
| if (hits.length > 1) return fmsDateSelect(hits); | |
| const span = document.createElement('span'); | |
| span.className = 'fms-date-label'; | |
| span.textContent = fmsWhenText(hits[0]); | |
| span.title = fmsWhyText(hits[0]); | |
| return span; | |
| } | |
| /** | |
| * Build the banner: one condensed row of 📅 [date] [badge] [Add →] [⚙][✕]. | |
| * | |
| * @param {Array<object>} hits Detected hits, in date order. | |
| * @param {string} subject Message subject, used as the event title. | |
| * @param {string} messageKey Identity from `fmsMessageKey`, so two messages in | |
| * one thread do not share created-event state. | |
| * @returns {HTMLElement} The banner element. | |
| */ | |
| function fmsBanner(hits, subject, messageKey) { | |
| const el = document.createElement('div'); | |
| el.className = 'fms-banner'; | |
| const row = document.createElement('div'); | |
| row.className = 'fms-row'; | |
| const dateCtl = fmsDateControl(hits); | |
| const getHit = () => (hits.length > 1 ? hits[+dateCtl.value] : hits[0]); | |
| let icon = fmsCalIcon(hits[0].date); | |
| let badge = fmsTypeBadge(hits[0]); | |
| const apply = fmsApplyButton(getHit, subject, messageKey); | |
| if (hits.length > 1) { | |
| dateCtl.addEventListener('change', () => { | |
| const hit = getHit(); | |
| const nextIcon = fmsCalIcon(hit.date); | |
| icon.replaceWith(nextIcon); | |
| icon = nextIcon; | |
| const next = fmsTypeBadge(hit); | |
| badge.replaceWith(next); | |
| badge = next; | |
| fmsApplyState(apply, hit, messageKey); | |
| }); | |
| } | |
| row.append(icon, dateCtl, badge, apply, fmsRightControls()); | |
| el.append(row); | |
| fmsApplyState(apply, hits[0], messageKey); | |
| return el; | |
| } | |
| /** @returns {string} The open message's subject, from the DOM or the title. */ | |
| function fmsFindSubject() { | |
| const el = document.querySelector('.v-Thread .u-text-xl'); | |
| if (el) return el.textContent.trim(); | |
| return document.title.replace(/ \| Fastmail$/, '').replace(/^.* \u2013 /, ''); | |
| } | |
| /** | |
| * @param {Element} card A `.v-MessageCard` element. | |
| * @returns {Date} The message's sent date, or now when unreadable. | |
| */ | |
| function fmsSentDate(card) { | |
| const timeEl = card.querySelector('.v-MessageCard-time'); | |
| return fmsParseSentDate(timeEl && timeEl.getAttribute('title')) || new Date(); | |
| } | |
| /** | |
| * A stable identity for one message. | |
| * | |
| * The URL only identifies the thread, so a thread with several expanded cards | |
| * would otherwise share one identity and cross-contaminate their "Added" | |
| * state. The sent date separates them. | |
| * | |
| * @param {Date} sent The message's sent date. | |
| * @returns {string} An identity string for {@link fmsHitKey}. | |
| */ | |
| function fmsMessageKey(sent) { | |
| const seg = location.pathname.split('/').filter(Boolean).pop() || ''; | |
| return seg + '@' + sent.getTime(); | |
| } | |
| /** | |
| * Add a banner to one message card, at most once. | |
| * | |
| * Side effects: marks the card with `data-fmsScanned` and inserts the banner. | |
| * A re-render clears the marker along with the node. | |
| * | |
| * @param {Element} card A `.v-MessageCard` element. | |
| */ | |
| function fmsScanCard(card) { | |
| if (card.dataset.fmsScanned) return; | |
| const bodyEl = card.querySelector('.v-Message'); | |
| if (!bodyEl) return; | |
| card.dataset.fmsScanned = '1'; | |
| const subject = fmsFindSubject(); | |
| const sent = fmsSentDate(card); | |
| const text = subject + '\n' + bodyEl.innerText; | |
| const hits = fmsDetectAll(text, sent, { dateOrder: fmsDateOrder() }); | |
| if (hits.length) { | |
| card.insertBefore(fmsBanner(hits, subject, fmsMessageKey(sent)), bodyEl); | |
| } | |
| } | |
| /** Scan every expanded message card on the page. */ | |
| function fmsScanAll() { | |
| document.querySelectorAll('.v-MessageCard').forEach(fmsScanCard); | |
| } | |
| // How many times, and how often, to look for the Edit button after a View | |
| // deep-link lands on the calendar page. | |
| const FMS_AUTO_EDIT_TRIES = 40; | |
| const FMS_AUTO_EDIT_INTERVAL_MS = 300; | |
| /** | |
| * View's `#fms-edit` marker: on the calendar page, open the event's Edit | |
| * dialog as soon as its detail popup has rendered. | |
| * | |
| * @param {number} tries Attempts remaining. | |
| */ | |
| function fmsAutoEditTick(tries) { | |
| const btn = [...document.querySelectorAll('button')] | |
| .find(b => b.textContent.trim() === 'Edit'); | |
| if (btn) { | |
| btn.click(); | |
| history.replaceState(null, '', location.pathname + location.search); | |
| return; | |
| } | |
| if (tries > 0) setTimeout(() => fmsAutoEditTick(tries - 1), FMS_AUTO_EDIT_INTERVAL_MS); | |
| } | |
| /** Start the auto-edit poll when the page was opened by a View button. */ | |
| function fmsMaybeAutoEdit() { | |
| if (window.name !== 'fms-edit' && location.hash !== '#fms-edit') return; | |
| window.name = ''; | |
| fmsAutoEditTick(FMS_AUTO_EDIT_TRIES); | |
| } | |
| // Extra rows in Fastmail's snooze menu, built from the open message. | |
| // | |
| // The menu is one of Fastmail's own views. Rather than reimplement snoozing -- | |
| // which spans threads, memos, labels-vs-folders mode, unread keywords and | |
| // creating the Snoozed folder -- an injected row calls the very method the | |
| // native rows call, `didSelect(date)`, on that same view. Everything after | |
| // that is Fastmail's code, unchanged. | |
| // Fastmail's class names, all load-bearing. If a redesign renames one, the | |
| // rows stop appearing and the native menu is left exactly as it was. | |
| const FMS_MENU_SEL = '.v-FutureTimeMenu'; | |
| const FMS_MENU_OPTION_SEL = 'li.v-MenuOption'; | |
| const FMS_MENU_SECTION_CLASS = 'v-MenuOption--lastOfSection'; | |
| // The flex row inside a native option's button, holding its label and time. | |
| // Only used when a native row cannot be copied; see {@link fmsPlainRowLabel}. | |
| const FMS_MENU_LABEL_CLASS = 'u-flex u-space-x-2 u-whitespace-nowrap'; | |
| // Marks rows this script added, so a re-render is not decorated twice. | |
| const FMS_MENU_MARK = 'data-fms-snooze'; | |
| // Our own class, so a group heading can be styled without a native one to copy. | |
| const FMS_MENU_HEADING_CLASS = 'fms-snooze-date'; | |
| // A date heading that expands, its caret, and the transparent button that | |
| // takes the click. Ours, because no native option behaves this way. | |
| const FMS_MENU_EXPANDER_CLASS = 'fms-snooze-more'; | |
| const FMS_MENU_CARET_CLASS = 'fms-snooze-caret'; | |
| const FMS_MENU_EXPANDER_HIT_CLASS = 'fms-snooze-hit'; | |
| const FMS_MENU_OPEN_CLASS = 'is-open'; | |
| // The same view type backs compose's "Schedule send". Only the snooze menu | |
| // leaves `lastCustomKey` at its default. | |
| const FMS_SNOOZE_CUSTOM_KEY = 'lastUsedFutureDelta'; | |
| // How many frames to wait for Fastmail to register the view for a menu that | |
| // has only just been inserted. | |
| const FMS_MENU_VIEW_TRIES = 3; | |
| /** | |
| * The page's own `window`, which is not always the one this script sees. | |
| * | |
| * Fastmail's CSP allows no inline script, no `blob:` and no `eval`, so on | |
| * Firefox a userscript manager cannot inject into the page context and runs | |
| * the script in a content-script sandbox instead. There `window.FastMail` is | |
| * absent and the page's globals live behind `wrappedJSObject`. Chrome's | |
| * managers inject through an extension API that page CSP does not govern, so | |
| * the first branch is the only one they ever take. | |
| * | |
| * Tested by capability rather than by browser, so this follows either engine | |
| * if its injection changes. | |
| * | |
| * @returns {Window} The window whose globals Fastmail defined. | |
| */ | |
| function fmsPageWindow() { | |
| const usable = w => !!(w && w.FastMail | |
| && typeof w.FastMail.getViewFromNode === 'function'); | |
| if (usable(window)) return window; | |
| const unwrapped = window.wrappedJSObject; | |
| return usable(unwrapped) ? unwrapped : window; | |
| } | |
| /** | |
| * Fastmail's in-page handle. | |
| * | |
| * @returns {?object} The handle, or null when the page has not exposed one. | |
| */ | |
| function fmsPageApi() { | |
| const FM = fmsPageWindow().FastMail; | |
| return (FM && typeof FM.getViewFromNode === 'function') ? FM : null; | |
| } | |
| /** | |
| * A date the page will accept. | |
| * | |
| * `didSelect` is Fastmail's own code, so from a sandbox it must be handed a | |
| * `Date` built by the page's constructor; one built here crosses the boundary | |
| * as a foreign object. In the page context this is the same constructor and | |
| * the copy costs nothing. | |
| * | |
| * @param {Date} date A wake-up time. | |
| * @returns {Date} The same instant, owned by the page. | |
| */ | |
| function fmsPageDate(date) { | |
| const PageDate = fmsPageWindow().Date; | |
| if (PageDate === Date) return date; | |
| try { return new PageDate(date.getTime()); } | |
| catch { return date; } | |
| } | |
| /** | |
| * @param {Element} el A menu element. | |
| * @returns {?object} The view that owns it, or null. | |
| */ | |
| function fmsViewFor(el) { | |
| const FM = fmsPageApi(); | |
| if (!FM) return null; | |
| try { return FM.getViewFromNode(el) || null; } | |
| catch { return null; } | |
| } | |
| /** | |
| * A snooze menu is a future-time menu that is not compose's schedule-send. | |
| * | |
| * @param {?object} view The view behind the menu element. | |
| * @returns {boolean} Whether to decorate it. | |
| */ | |
| function fmsIsSnoozeMenu(view) { | |
| if (!view || typeof view.didSelect !== 'function') return false; | |
| if (typeof view.customDateChosen !== 'function') return false; | |
| let key; | |
| try { key = view.get('lastCustomKey'); } | |
| catch { return false; } | |
| return key === FMS_SNOOZE_CUSTOM_KEY; | |
| } | |
| /** | |
| * Fastmail's remembered custom snooze offset, so a suggestion landing on the | |
| * menu's "last custom" row can be dropped instead of duplicating it. | |
| * | |
| * @returns {?number} The offset in ms from the end of today, or null. | |
| */ | |
| function fmsLastCustomDelta() { | |
| const FM = fmsPageApi(); | |
| if (!FM || !FM.localPrefs || typeof FM.localPrefs.get !== 'function') return null; | |
| let v; | |
| try { v = FM.localPrefs.get(FMS_SNOOZE_CUSTOM_KEY); } | |
| catch { return null; } | |
| return typeof v === 'number' ? v : null; | |
| } | |
| /** | |
| * The message a snooze would apply to: the one on screen. | |
| * | |
| * @returns {?{text: string, sent: Date}} Its text and sent date, or null when | |
| * no single message is open to read. | |
| */ | |
| function fmsOpenMessage() { | |
| const cards = [...document.querySelectorAll('.v-MessageCard')] | |
| .filter(card => card.querySelector('.v-Message')); | |
| if (cards.length !== 1) return null; | |
| const card = cards[0]; | |
| return { | |
| text: fmsFindSubject() + '\n' + card.querySelector('.v-Message').innerText, | |
| sent: fmsSentDate(card), | |
| }; | |
| } | |
| /** | |
| * Whether the menu is writing 12- or 24-hour times, read off its own rows. | |
| * | |
| * The choice is Fastmail's setting, not the browser's locale, and Fastmail | |
| * does not expose it. Its built-in options are already rendered in it, so the | |
| * rows we sit beside are the reliable place to learn it from. | |
| * | |
| * @param {Array<Element>} options The menu's native `li.v-MenuOption`s. | |
| * @returns {?boolean} True for 12-hour, false for 24-hour, null if no row | |
| * shows a time and the browser's own locale should decide. | |
| */ | |
| function fmsMenuHour12(options) { | |
| for (const li of options) { | |
| const p = [...li.querySelectorAll('p')].pop(); | |
| const text = p && p.textContent; | |
| if (!text || !/\d:\d{2}/.test(text)) continue; | |
| return /[ap]\.?\s?m\.?/i.test(text); | |
| } | |
| return null; | |
| } | |
| /** | |
| * The right-hand label, in whichever clock the menu's own rows use. | |
| * | |
| * @param {Date} d A wake-up time. | |
| * @param {Date} now Current time. | |
| * @param {?boolean} [hour12] From {@link fmsMenuHour12}. | |
| * @returns {string} e.g. "Sat, Oct 24 08:00" or "Sat, Oct 24 8:00 AM". | |
| */ | |
| function fmsSnoozeWhenText(d, now, hour12) { | |
| const shape = { hour: hour12 === false ? '2-digit' : 'numeric', minute: '2-digit' }; | |
| if (hour12 === true || hour12 === false) shape.hour12 = hour12; | |
| const time = d.toLocaleTimeString(undefined, shape); | |
| const sameWeek = d - now < 7 * 86400000; | |
| const day = d.toLocaleDateString(undefined, | |
| sameWeek ? { weekday: 'short' } : { weekday: 'short', month: 'short', day: 'numeric' }); | |
| return day + ' ' + time; | |
| } | |
| /** | |
| * Copy a native row's label markup, filled in with this suggestion's text. | |
| * | |
| * Copying rather than rebuilding keeps every wrapper Fastmail nests inside the | |
| * button. One of them is load-bearing: the label sits in a stretching | |
| * `<span class="label">`, and without it the right-hand time sits against the | |
| * label text rather than at the menu's edge, so the row fails to line up with | |
| * the native ones above it. | |
| * | |
| * @param {?Element} modelBtn A native row's button, or null. | |
| * @param {string} why Left-hand text. | |
| * @param {string} when Right-hand text. | |
| * @returns {?Element} The filled copy, or null when the model is shaped | |
| * differently and so has nothing to copy. | |
| */ | |
| function fmsCloneRowLabel(modelBtn, why, when) { | |
| const modelLabel = modelBtn && modelBtn.firstElementChild; | |
| if (!modelLabel || modelLabel.querySelectorAll('p').length !== 2) return null; | |
| const label = modelLabel.cloneNode(true); | |
| const [left, right] = label.querySelectorAll('p'); | |
| left.textContent = why; | |
| right.textContent = when; | |
| return label; | |
| } | |
| /** | |
| * The label to fall back on when no native row can be copied: the same shape, | |
| * with the classes Fastmail used when this was written. | |
| * | |
| * @param {string} why Left-hand text. | |
| * @param {string} when Right-hand text. | |
| * @returns {HTMLDivElement} The label row. | |
| */ | |
| function fmsPlainRowLabel(why, when) { | |
| const label = document.createElement('div'); | |
| label.className = FMS_MENU_LABEL_CLASS; | |
| const left = document.createElement('p'); | |
| left.className = 'u-flex-grow'; | |
| left.textContent = why; | |
| const right = document.createElement('p'); | |
| right.className = 'u-flex-none u-color-unimportant'; | |
| right.textContent = when; | |
| label.append(left, right); | |
| return label; | |
| } | |
| /** | |
| * Build one menu row, borrowing the native row's classes so it inherits the | |
| * menu's styling, spacing and dark mode. | |
| * | |
| * @param {Element} model A native `li.v-MenuOption` to copy classes from. | |
| * @param {{date: Date, why: string}} hit The suggestion. | |
| * @param {Date} now Current time. | |
| * @param {function} onPick Called with the date when the row is chosen. | |
| * @param {?boolean} [hour12] From {@link fmsMenuHour12}. | |
| * @returns {HTMLLIElement} The row. | |
| */ | |
| function fmsSnoozeRow(model, hit, now, onPick, hour12) { | |
| const li = document.createElement('li'); | |
| li.className = model.className.replace(FMS_MENU_SECTION_CLASS, '').trim(); | |
| li.setAttribute(FMS_MENU_MARK, '1'); | |
| const btn = document.createElement('button'); | |
| const modelBtn = model.querySelector('button'); | |
| btn.type = 'button'; | |
| if (modelBtn) btn.className = modelBtn.className; | |
| btn.title = hit.why; | |
| const when = fmsSnoozeWhenText(hit.date, now, hour12); | |
| btn.append(fmsCloneRowLabel(modelBtn, hit.why, when) | |
| || fmsPlainRowLabel(hit.why, when)); | |
| btn.addEventListener('click', event => { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| onPick(hit.date); | |
| }); | |
| li.append(btn); | |
| return li; | |
| } | |
| /** | |
| * Hand a date to the menu's own selection handler and close the popover, the | |
| * same two steps a native row performs. | |
| * | |
| * @param {object} view The snooze menu's view. | |
| * @param {Date} date The chosen wake-up time. | |
| */ | |
| function fmsSnoozePick(view, date) { | |
| try { | |
| view.didSelect(fmsPageDate(date)); | |
| } catch (e) { | |
| console.warn('[fms] snooze failed', e); | |
| return; | |
| } | |
| try { view.hide(); } | |
| catch { /* the menu closes itself on the next interaction */ } | |
| } | |
| /** | |
| * A group's heading: the date its options are built from, written out beside | |
| * the same tear-off calendar the Smart Schedule banner uses. | |
| * | |
| * @param {Element} model A native `li.v-MenuOption` to copy classes from. | |
| * @param {{date: Date, label: string}} group The group being introduced. | |
| * @returns {HTMLLIElement} A non-interactive row. | |
| */ | |
| function fmsSnoozeHeadingRow(model, group) { | |
| const li = document.createElement('li'); | |
| li.className = model.className.replace(FMS_MENU_SECTION_CLASS, '').trim(); | |
| li.classList.add(FMS_MENU_HEADING_CLASS); | |
| li.setAttribute(FMS_MENU_MARK, '1'); | |
| const text = document.createElement('span'); | |
| text.textContent = group.label; | |
| li.append(fmsCalIcon(group.date), text); | |
| return li; | |
| } | |
| /** | |
| * Close our block with Fastmail's section divider, below whichever of our rows | |
| * is currently last. Expanding a date inserts rows after it, so which row ends | |
| * the block changes as the menu is used. | |
| * | |
| * @param {Element} menu The snooze menu. | |
| */ | |
| function fmsMarkSectionEnd(menu) { | |
| const ours = [...menu.querySelectorAll('li[' + FMS_MENU_MARK + ']')] | |
| .filter(li => !li.hidden); | |
| for (const li of ours) li.classList.remove(FMS_MENU_SECTION_CLASS); | |
| if (ours.length) ours[ours.length - 1].classList.add(FMS_MENU_SECTION_CLASS); | |
| } | |
| /** | |
| * A collapsed row for a date, which expands its options in place. | |
| * | |
| * Fastmail's own submenu would be the natural fit, but building one means | |
| * handing `ButtonView` a `{target, method}` pair for Fastmail to call back | |
| * into. A content-script function cannot be called from the page context, so | |
| * on Firefox that is unreachable. Our own rows and our own listeners work in | |
| * both, so the options are revealed in the list instead of in a flyout. | |
| * | |
| * @param {Element} model A native `li.v-MenuOption` to copy classes from. | |
| * @param {{date: Date, label: string}} group The date being folded. | |
| * @param {function(): Array<HTMLLIElement>} buildRows Makes the option rows, | |
| * called once, the first time the row is expanded. | |
| * @returns {HTMLLIElement} The collapsed row. | |
| */ | |
| function fmsSnoozeExpanderRow(model, group, buildRows) { | |
| const li = fmsSnoozeHeadingRow(model, group); | |
| li.classList.add(FMS_MENU_EXPANDER_CLASS); | |
| const caret = document.createElement('span'); | |
| caret.className = FMS_MENU_CARET_CLASS; | |
| caret.setAttribute('aria-hidden', 'true'); | |
| li.append(caret); | |
| const button = document.createElement('button'); | |
| button.type = 'button'; | |
| button.className = FMS_MENU_EXPANDER_HIT_CLASS; | |
| button.setAttribute('aria-expanded', 'false'); | |
| button.setAttribute('aria-label', group.label); | |
| li.append(button); | |
| let rows = null; | |
| button.addEventListener('click', event => { | |
| // Fastmail's menu closes on a click it considers a selection; this is a | |
| // disclosure, so the event stops here. | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| if (!rows) { | |
| rows = buildRows(); | |
| li.after(...rows); | |
| } | |
| const open = li.classList.toggle(FMS_MENU_OPEN_CLASS); | |
| button.setAttribute('aria-expanded', open ? 'true' : 'false'); | |
| for (const row of rows) row.hidden = !open; | |
| const menu = li.closest(FMS_MENU_SEL); | |
| if (menu) fmsMarkSectionEnd(menu); | |
| }); | |
| return li; | |
| } | |
| /** | |
| * Add suggestions to one snooze menu, at most once. | |
| * | |
| * Each date found becomes a heading followed by its wake-up times, and every | |
| * group is closed with Fastmail's own section divider so the groups read | |
| * apart from each other and from the menu's built-in options above them. | |
| * | |
| * @param {Element} menu A `.v-FutureTimeMenu` element. | |
| * @param {object} view Its view. | |
| */ | |
| function fmsDecorateSnoozeMenu(menu, view) { | |
| const options = [...menu.querySelectorAll(FMS_MENU_OPTION_SEL)]; | |
| // The last option is "Choose a date and time"; ours go above it, and the | |
| // one before it is the model for their styling. | |
| const custom = options[options.length - 1]; | |
| const model = options.find(o => o !== custom) || custom; | |
| if (!custom || !model) return; | |
| const message = fmsOpenMessage(); | |
| if (!message) return; | |
| const now = new Date(); | |
| const groups = fmsSnoozeGroups(message.text, message.sent, now, | |
| Object.assign(fmsSnoozeOptions(), { | |
| dateOrder: fmsDateOrder(), | |
| native: fmsSnoozeNativeTimes(now, fmsLastCustomDelta()), | |
| })); | |
| if (!groups.length) return; | |
| const hour12 = fmsMenuHour12(options); | |
| const pick = date => fmsSnoozePick(view, date); | |
| const optionRows = group => group.options.map(hit => | |
| fmsSnoozeRow(model, hit, now, pick, hour12)); | |
| // The soonest date is worth the room. Any others collapse to a single row | |
| // each, so one wordy message cannot push the custom picker off the screen. | |
| const rows = [fmsSnoozeHeadingRow(model, groups[0]), ...optionRows(groups[0])]; | |
| for (const group of groups.slice(1)) { | |
| rows.push(fmsSnoozeExpanderRow(model, group, () => { | |
| const built = optionRows(group); | |
| for (const row of built) row.hidden = true; | |
| return built; | |
| })); | |
| } | |
| // Divide the first group from Fastmail's own options above it. | |
| const lastNative = options[options.length - 2]; | |
| if (lastNative) lastNative.classList.add(FMS_MENU_SECTION_CLASS); | |
| custom.before(...rows); | |
| fmsMarkSectionEnd(menu); | |
| } | |
| /** | |
| * Decorate a menu once Fastmail has registered its view. A menu inserted this | |
| * frame may not be registered yet, so retry for a few frames. | |
| * | |
| * @param {Element} menu A `.v-FutureTimeMenu` element. | |
| * @param {number} tries Attempts remaining. | |
| */ | |
| function fmsTrySnoozeMenu(menu, tries) { | |
| if (!menu.isConnected || menu.querySelector('[' + FMS_MENU_MARK + ']')) return; | |
| if (!fmsSnoozeEnabled()) return; | |
| const view = fmsViewFor(menu); | |
| if (fmsIsSnoozeMenu(view)) { | |
| fmsDecorateSnoozeMenu(menu, view); | |
| return; | |
| } | |
| // A view that resolved but is not the snooze menu is compose's schedule | |
| // send: leave it alone. Only an unresolved view is worth retrying. | |
| if (view || tries <= 0) return; | |
| requestAnimationFrame(() => fmsTrySnoozeMenu(menu, tries - 1)); | |
| } | |
| /** | |
| * Watch for the snooze menu opening. Separate from the message-card observer | |
| * in {@link fmsMain}, which is debounced: a popover has to be decorated in | |
| * the frame it appears, not 400ms later. | |
| * | |
| * The observer is installed unconditionally. Fastmail attaches | |
| * `getViewFromNode` when its app module finishes loading, which is after this | |
| * script runs at `document-idle` -- testing for it here would mean never | |
| * watching at all. {@link fmsTrySnoozeMenu} checks for it when a menu opens, | |
| * by which point the app is up. | |
| */ | |
| function fmsWatchSnoozeMenus() { | |
| new MutationObserver(records => { | |
| for (const record of records) { | |
| for (const node of record.addedNodes) { | |
| if (node.nodeType !== 1) continue; | |
| const menu = node.matches(FMS_MENU_SEL) | |
| ? node : node.querySelector(FMS_MENU_SEL); | |
| if (menu) fmsTrySnoozeMenu(menu, FMS_MENU_VIEW_TRIES); | |
| } | |
| } | |
| }).observe(document.body, { childList: true, subtree: true }); | |
| } | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = { | |
| fmsIsSnoozeMenu, fmsSnoozeRow, fmsSnoozeWhenText, fmsSnoozePick, | |
| fmsSnoozeHeadingRow, fmsCloneRowLabel, fmsPlainRowLabel, fmsMenuHour12, | |
| fmsSnoozeExpanderRow, fmsMarkSectionEnd, fmsPageWindow, fmsPageDate, | |
| fmsDecorateSnoozeMenu, fmsTrySnoozeMenu, fmsWatchSnoozeMenus, | |
| }; | |
| } | |
| // How long to let Fastmail's DOM settle before re-scanning for message cards. | |
| const FMS_SCAN_DEBOUNCE_MS = 400; | |
| // Entry point. Installs a body-wide observer for the page's lifetime; message | |
| // cards arrive and re-render as the SPA navigates. | |
| (function fmsMain() { | |
| fmsInjectStyles(); | |
| fmsMaybeAutoEdit(); | |
| fmsWatchSnoozeMenus(); | |
| fmsScanAll(); | |
| let timer = null; | |
| new MutationObserver(() => { | |
| clearTimeout(timer); | |
| timer = setTimeout(fmsScanAll, FMS_SCAN_DEBOUNCE_MS); | |
| }).observe(document.body, { childList: true, subtree: true }); | |
| })(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment