Instantly share code, notes, and snippets.
Created
August 6, 2026 02:56
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save e-volusian/b9a94eddab3f8c3ed4c6ffc1db3df404 to your computer and use it in GitHub Desktop.
Fastmail - put notes in the right sidebar
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 Notes Sidebar | |
| // @namespace fastmail-notes-sidebar | |
| // @version 1.0.4 | |
| // @description Read/write Fastmail Notes from the mail right sidebar | |
| // @match https://app.fastmail.com/* | |
| // @run-at document-idle | |
| // @grant none | |
| // @noframes | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| function sortNotes(notes) { | |
| return [...notes].sort((a, b) => | |
| (b.isFlagged - a.isFlagged) || | |
| String(b.lastSaved).localeCompare(String(a.lastSaved)) | |
| ); | |
| } | |
| function makeSnippet(note, maxLen = 80) { | |
| const src = note.body == null ? '' : String(note.body); | |
| const raw = note.isHTML ? src.replace(/<[^>]+>/g, ' ') : src; | |
| const clean = raw | |
| .replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>') | |
| .replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&') | |
| .replace(/\s+/g, ' ').trim(); | |
| return clean.length > maxLen ? clean.slice(0, maxLen - 1) + '…' : clean; | |
| } | |
| function debounce(fn, ms) { | |
| let timer = null, lastArgs = null; | |
| const wrapped = (...args) => { | |
| lastArgs = args; | |
| clearTimeout(timer); | |
| timer = setTimeout(() => { timer = null; fn(...lastArgs); }, ms); | |
| }; | |
| wrapped.flush = () => { | |
| if (timer === null) return; | |
| clearTimeout(timer); timer = null; fn(...lastArgs); | |
| }; | |
| wrapped.pending = () => timer !== null; | |
| return wrapped; | |
| } | |
| function escapeHtml(s) { | |
| return s.replace(/[&<>"']/g, c => | |
| ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); | |
| } | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = { sortNotes, makeSnippet, debounce, escapeHtml }; | |
| } | |
| const NOTES_CAP = 'https://www.fastmail.com/dev/notes'; | |
| function findNotesAccountId(accounts) { | |
| for (const [id, acct] of Object.entries(accounts || {})) { | |
| if ((acct.accountCapabilities || {})[NOTES_CAP]) return id; | |
| } | |
| return null; | |
| } | |
| // Direct page access; only works when the script runs in the page world. | |
| function pageBackend() { | |
| 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)), | |
| }; | |
| } | |
| function pickSession(sessions, uParam) { | |
| if (!Array.isArray(sessions) || sessions.length === 0) return null; | |
| return sessions.find(s => s.accounts && s.accounts['u' + uParam]) || sessions[0]; | |
| } | |
| function currentUParam() { | |
| return new URLSearchParams(location.search).get('u') || ''; | |
| } | |
| function readSession() { | |
| let sessions; | |
| try { sessions = JSON.parse(localStorage.getItem('sessions')); } | |
| catch (e) { return null; } | |
| const s = pickSession(sessions, currentUParam()); | |
| return (s && s.apiUrl && s.accessToken) ? s : null; | |
| } | |
| function unwrapJmapResponse(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 || {}; | |
| } | |
| // The API needs the session cookies AND the bearer token, plus the u= param. | |
| async function jmapFetch(session, method, args) { | |
| const sep = session.apiUrl.includes('?') ? '&' : '?'; | |
| const res = await fetch(session.apiUrl + sep + 'u=' + currentUParam(), { | |
| method: 'POST', | |
| credentials: 'include', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': 'Bearer ' + session.accessToken, | |
| }, | |
| body: JSON.stringify({ | |
| using: ['urn:ietf:params:jmap:core', NOTES_CAP], | |
| methodCalls: [[method, args, '0']], | |
| }), | |
| }); | |
| if (!res.ok) throw new Error('JMAP HTTP ' + res.status); | |
| return unwrapJmapResponse(await res.json()); | |
| } | |
| // Works from a sandboxed userscript world: localStorage is shared with the page. | |
| function sessionBackend() { | |
| const session = readSession(); | |
| 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(readSession() || session, method, args), | |
| }; | |
| } | |
| function waitForBackend(timeoutMs = 20000) { | |
| return new Promise((resolve, reject) => { | |
| const started = Date.now(); | |
| (function poll() { | |
| const backends = [pageBackend(), sessionBackend()].filter(Boolean); | |
| const ready = backends.find(b => findNotesAccountId(b.accounts)); | |
| if (ready) return resolve(ready); | |
| if (Date.now() - started > timeoutMs) { | |
| return reject(new Error(backends.length | |
| ? 'no notes-capable account' | |
| : 'no Fastmail session found')); | |
| } | |
| setTimeout(poll, 250); | |
| })(); | |
| }); | |
| } | |
| function makeNotesApi(backend, accountId) { | |
| const call = (method, args) => backend.call(method, { accountId, ...args }); | |
| return { | |
| async list() { | |
| const res = await call('Note/get', { ids: null }); | |
| return res.list || []; | |
| }, | |
| async get(id) { | |
| const res = await call('Note/get', { ids: [id] }); | |
| return (res.list || []).find(n => n.id === id) || null; | |
| }, | |
| async create(props) { | |
| const withDate = Object.assign( | |
| { lastSaved: new Date().toISOString().replace(/\.\d+Z$/, 'Z') }, props); | |
| const res = await call('Note/set', { create: { n1: withDate } }); | |
| if (!res.created || !res.created.n1) throw new Error('create failed'); | |
| return Object.assign({}, withDate, res.created.n1); | |
| }, | |
| async update(id, props) { | |
| const res = await call('Note/set', { update: { [id]: props } }); | |
| if (res.notUpdated && res.notUpdated[id]) throw new Error('update failed'); | |
| }, | |
| async destroy(id) { | |
| const res = await call('Note/set', { destroy: [id] }); | |
| if (res.notDestroyed && res.notDestroyed[id]) throw new Error('delete failed'); | |
| }, | |
| }; | |
| } | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = { findNotesAccountId, pickSession, unwrapJmapResponse, makeNotesApi }; | |
| } | |
| const FMN_CSS = ` | |
| .fmn-panel { display: flex; flex-direction: column; height: 100%; | |
| font: inherit; color: var(--color-text, #1a1a1a); | |
| background: var(--color-background, #fff); overflow: hidden; } | |
| .fmn-header { display: flex; align-items: center; gap: 8px; | |
| padding: 8px 12px; border-bottom: 1px solid var(--color-border, #ddd); } | |
| .fmn-header .fmn-title { font-weight: 600; flex: 1; } | |
| .fmn-btn { border: 1px solid var(--color-border, #ccc); border-radius: 4px; | |
| background: transparent; color: inherit; padding: 4px 10px; cursor: pointer; } | |
| .fmn-btn:hover { background: rgba(128,128,128,.12); } | |
| .fmn-btn--primary { background: var(--color-blue, #2e6da4); color: #fff; | |
| border-color: transparent; } | |
| .fmn-btn--danger { color: #c0392b; border-color: #c0392b; } | |
| .fmn-list { flex: 1; overflow-y: auto; margin: 0; padding: 0; list-style: none; } | |
| .fmn-list-item { padding: 10px 12px; cursor: pointer; | |
| border-bottom: 1px solid var(--color-border, #eee); } | |
| .fmn-list-item:hover { background: rgba(128,128,128,.08); } | |
| .fmn-list-item .fmn-item-title { font-weight: 600; margin-bottom: 2px; | |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| .fmn-list-item .fmn-item-snippet { font-size: .85em; opacity: .7; | |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| .fmn-flag { color: #e67e22; margin-right: 4px; } | |
| .fmn-empty { padding: 24px 12px; text-align: center; opacity: .6; } | |
| .fmn-editor-title { border: none; outline: none; font-weight: 700; | |
| font-size: 1.05em; padding: 10px 12px; background: transparent; color: inherit; | |
| border-bottom: 1px solid var(--color-border, #eee); } | |
| .fmn-toolbar { display: flex; gap: 4px; padding: 6px 10px; | |
| border-bottom: 1px solid var(--color-border, #eee); } | |
| .fmn-toolbar .fmn-btn { min-width: 32px; min-height: 30px; } | |
| .fmn-toolbar.fmn-disabled .fmn-btn { opacity: .35; pointer-events: none; } | |
| .fmn-editor-body { flex: 1; min-height: 0; overflow-y: auto; padding: 12px; | |
| outline: none; white-space: pre-wrap; overflow-wrap: break-word; } | |
| .fmn-editor-body ul, .fmn-editor-body ol { white-space: normal; } | |
| .fmn-status { font-size: .8em; opacity: .6; padding: 4px 12px; min-height: 1.4em; } | |
| .fmn-banner { background: #fdecea; color: #b71c1c; padding: 8px 12px; | |
| display: flex; align-items: center; gap: 8px; font-size: .9em; } | |
| .fmn-banner button { margin-left: auto; } | |
| .fmn-fab { position: fixed; right: 16px; bottom: 16px; z-index: 2147483000; | |
| width: 48px; height: 48px; border-radius: 50%; border: none; cursor: pointer; | |
| background: var(--color-blue, #2e6da4); color: #fff; font-size: 22px; | |
| box-shadow: 0 2px 8px rgba(0,0,0,.35); } | |
| .fmn-overlay { position: fixed; inset: 0; z-index: 2147483001; | |
| background: var(--color-background, #fff); display: flex; } | |
| .fmn-overlay .fmn-panel { flex: 1; } | |
| .fmn-overlay .fmn-header { padding-right: 56px; } | |
| @media (max-width: 800px) { | |
| .fmn-btn, .fmn-list-item { min-height: 40px; } | |
| }`; | |
| function injectStyles() { | |
| if (document.getElementById('fmn-styles')) return; | |
| const style = document.createElement('style'); | |
| style.id = 'fmn-styles'; | |
| style.textContent = FMN_CSS; | |
| document.head.appendChild(style); | |
| } | |
| function sanitizeNoteHtml(html) { | |
| const doc = new DOMParser().parseFromString(html || '', 'text/html'); | |
| doc.querySelectorAll('script,style,iframe,object,embed,link,meta,form,base') | |
| .forEach((el) => el.remove()); | |
| for (const el of doc.body.querySelectorAll('*')) { | |
| for (const attr of [...el.attributes]) { | |
| const name = attr.name.toLowerCase(); | |
| const val = String(attr.value).trim().toLowerCase(); | |
| if (name.startsWith('on')) el.removeAttribute(attr.name); | |
| else if ((name === 'href' || name === 'src' || name === 'xlink:href') && | |
| (val.startsWith('javascript:') || val.startsWith('data:'))) { | |
| el.removeAttribute(attr.name); | |
| } | |
| } | |
| } | |
| return doc.body.innerHTML; | |
| } | |
| function createPanel(api) { | |
| const el = document.createElement('div'); | |
| el.className = 'fmn-panel'; | |
| // contain key/mouse/focus events so Fastmail's shortcuts and focus | |
| // manager (which reclaims focus from unmanaged editables) ignore the panel | |
| for (const type of ['keydown', 'keypress', 'keyup', 'pointerdown', | |
| 'pointerup', 'mousedown', 'mouseup', 'click', 'focusin']) { | |
| el.addEventListener(type, (ev) => ev.stopPropagation()); | |
| } | |
| const state = { notes: [], current: null, saveDirty: false }; | |
| const banner = document.createElement('div'); | |
| const content = document.createElement('div'); | |
| content.style.cssText = 'display:flex;flex-direction:column;flex:1;min-height:0'; | |
| el.append(banner, content); | |
| function showError(msg, retry, kind = 'general') { | |
| banner.className = 'fmn-banner'; | |
| banner.dataset.kind = kind; | |
| banner.innerHTML = `<span>${escapeHtml(msg)}</span>`; | |
| const btn = document.createElement('button'); | |
| btn.className = 'fmn-btn'; | |
| btn.textContent = 'Retry'; | |
| btn.onclick = () => { clearError(); retry(); }; | |
| banner.appendChild(btn); | |
| } | |
| function clearError(kind) { | |
| if (kind && banner.dataset.kind && banner.dataset.kind !== kind) return; | |
| banner.className = ''; banner.innerHTML = ''; delete banner.dataset.kind; | |
| } | |
| function renderList() { | |
| state.current = null; | |
| content.innerHTML = | |
| `<div class="fmn-header"><span class="fmn-title">Notes</span> | |
| <button class="fmn-btn fmn-btn--primary" data-act="new">New note</button></div> | |
| <ul class="fmn-list"></ul>`; | |
| const ul = content.querySelector('.fmn-list'); | |
| const notes = sortNotes(state.notes); | |
| if (!notes.length) ul.innerHTML = '<li class="fmn-empty">No notes yet</li>'; | |
| for (const note of notes) { | |
| const li = document.createElement('li'); | |
| li.className = 'fmn-list-item'; | |
| li.innerHTML = | |
| `<div class="fmn-item-title">${note.isFlagged ? '<span class="fmn-flag">⚑</span>' : ''}${escapeHtml(note.title || 'Untitled')}</div> | |
| <div class="fmn-item-snippet">${escapeHtml(makeSnippet(note))}</div>`; | |
| li.onclick = () => openEditor(note); | |
| ul.appendChild(li); | |
| } | |
| content.querySelector('[data-act="new"]').onclick = createNote; | |
| } | |
| async function createNote() { | |
| try { | |
| const note = await api.create({ title: '', body: '', isHTML: true }); | |
| state.notes.push(note); | |
| openEditor(note); | |
| } catch (e) { showError('Could not create note', createNote); } | |
| } | |
| function openEditor(note) { | |
| state.current = note; | |
| const editable = !note.isReadOnly; | |
| content.innerHTML = | |
| `<div class="fmn-header"> | |
| <button class="fmn-btn" data-act="back">‹ Back</button> | |
| <span class="fmn-title"></span> | |
| ${editable ? '<button class="fmn-btn fmn-btn--danger" data-act="del">Delete</button>' : ''} | |
| </div> | |
| <input class="fmn-editor-title" placeholder="Title" ${editable ? '' : 'disabled'}> | |
| <div class="fmn-toolbar ${note.isHTML && editable ? '' : 'fmn-disabled'}" | |
| title="${note.isHTML ? '' : 'Plain-text note — formatting disabled'}"> | |
| <button class="fmn-btn" data-cmd="bold"><b>B</b></button> | |
| <button class="fmn-btn" data-cmd="italic"><i>I</i></button> | |
| <button class="fmn-btn" data-cmd="insertUnorderedList">• List</button> | |
| <button class="fmn-btn" data-cmd="insertOrderedList">1. List</button> | |
| </div> | |
| <div class="fmn-editor-body" contenteditable="${editable}"></div> | |
| <div class="fmn-status"></div>`; | |
| const titleInput = content.querySelector('.fmn-editor-title'); | |
| const body = content.querySelector('.fmn-editor-body'); | |
| titleInput.value = note.title || ''; | |
| if (note.isHTML) body.innerHTML = sanitizeNoteHtml(note.body); | |
| else body.textContent = note.body || ''; | |
| content.querySelector('[data-act="back"]').onclick = () => { flushSave(); refreshList(); }; | |
| wireDelete(note); | |
| wireToolbar(body); | |
| if (editable) { | |
| titleInput.addEventListener('input', scheduleSave); | |
| body.addEventListener('input', scheduleSave); | |
| titleInput.addEventListener('blur', flushSave); | |
| body.addEventListener('blur', flushSave); | |
| } | |
| } | |
| function wireToolbar(body) { | |
| for (const btn of content.querySelectorAll('[data-cmd]')) { | |
| // block the button's focus-stealing mousedown so the editor keeps | |
| // its selection; execCommand needs a live selection to do anything | |
| btn.addEventListener('mousedown', (ev) => ev.preventDefault()); | |
| btn.onclick = () => { | |
| const sel = document.getSelection(); | |
| if (!sel.rangeCount || !body.contains(sel.anchorNode)) body.focus(); | |
| document.execCommand(btn.dataset.cmd); | |
| scheduleSave(); | |
| }; | |
| } | |
| } | |
| function wireDelete(note) { | |
| const del = content.querySelector('[data-act="del"]'); | |
| if (!del) return; | |
| const doDelete = async () => { | |
| try { | |
| await api.destroy(note.id); | |
| state.notes = state.notes.filter(n => n.id !== note.id); | |
| refreshList(); | |
| } catch (e) { showError('Delete failed', doDelete); } | |
| }; | |
| del.onclick = () => { | |
| if (del.dataset.armed !== '1') { | |
| del.dataset.armed = '1'; | |
| del.textContent = 'Really delete?'; | |
| setTimeout(() => { del.dataset.armed = ''; del.textContent = 'Delete'; }, 4000); | |
| return; | |
| } | |
| doDelete(); | |
| }; | |
| } | |
| function readEditor() { | |
| const note = state.current; | |
| const titleInput = content.querySelector('.fmn-editor-title'); | |
| const body = content.querySelector('.fmn-editor-body'); | |
| if (!note || !titleInput || !body) return null; | |
| return { | |
| title: titleInput.value, | |
| body: note.isHTML ? body.innerHTML : body.innerText, | |
| isHTML: note.isHTML, | |
| }; | |
| } | |
| function setStatus(text) { | |
| const s = content.querySelector('.fmn-status'); | |
| if (s) s.textContent = text; | |
| } | |
| const doSave = async () => { | |
| const note = state.current; | |
| const props = readEditor(); | |
| if (!note || !props || note.isReadOnly) return; | |
| await saveNote(note, props); | |
| }; | |
| async function saveNote(note, props) { | |
| setStatus('Saving…'); | |
| try { | |
| await api.update(note.id, props); | |
| Object.assign(note, props); | |
| state.saveDirty = false; | |
| setStatus('Saved'); | |
| clearError('save'); | |
| } catch (e) { | |
| state.saveDirty = true; | |
| showError(`Save failed: "${note.title || 'Untitled'}" — edits kept until retried`, | |
| () => saveNote(note, props), 'save'); | |
| setStatus(''); | |
| } | |
| } | |
| const debouncedSave = debounce(doSave, 750); | |
| function scheduleSave() { state.saveDirty = true; setStatus('…'); debouncedSave(); } | |
| function flushSave() { | |
| if (debouncedSave.pending()) debouncedSave.flush(); | |
| else if (state.saveDirty) doSave(); | |
| } | |
| async function refreshList() { | |
| try { | |
| state.notes = await api.list(); | |
| clearError('load'); | |
| } catch (e) { showError('Could not load notes', refreshList, 'load'); } | |
| renderList(); | |
| } | |
| document.addEventListener('visibilitychange', () => { | |
| if (document.visibilityState === 'hidden') flushSave(); | |
| }); | |
| return { | |
| el, | |
| open() { refreshList(); }, | |
| flushSave, | |
| }; | |
| } | |
| function fmnFindTabStrip() { | |
| for (const label of document.querySelectorAll('.v-PushSelect-option')) { | |
| if (label.textContent.trim() === 'Calendar') return label.closest('.v-PushSelect'); | |
| } | |
| return null; | |
| } | |
| function fmnFindSidebarContent(strip) { | |
| const toolbar = strip.closest('.v-Toolbar'); | |
| if (!toolbar || !toolbar.parentElement) return null; | |
| let el = toolbar.nextElementSibling; | |
| while (el && el.classList.contains('fmn-panel')) el = el.nextElementSibling; | |
| return el; | |
| } | |
| function fmnSelectNotesTab(strip, panel) { | |
| const content = fmnFindSidebarContent(strip); | |
| if (content) content.style.display = 'none'; | |
| strip.querySelectorAll('.v-PushSelect-option.is-selected') | |
| .forEach(o => o.classList.remove('is-selected')); | |
| strip.querySelector('.fmn-tab').classList.add('is-selected'); | |
| const host = strip.closest('.v-Toolbar').parentElement; | |
| if (!panel.el.parentElement) host.appendChild(panel.el); | |
| panel.el.style.display = ''; | |
| panel.open(); | |
| } | |
| function fmnDeselectNotesTab(strip, panel) { | |
| const tab = strip.querySelector('.fmn-tab'); | |
| if (tab) tab.classList.remove('is-selected'); | |
| panel.flushSave(); | |
| panel.el.style.display = 'none'; | |
| const content = fmnFindSidebarContent(strip); | |
| if (content) content.style.display = ''; | |
| } | |
| function fmnInjectTab(strip, panel) { | |
| if (!strip.dataset.fmnWired) { | |
| strip.dataset.fmnWired = '1'; | |
| strip.addEventListener('click', (ev) => { | |
| const opt = ev.target.closest('.v-PushSelect-option'); | |
| if (opt && !opt.classList.contains('fmn-tab')) fmnDeselectNotesTab(strip, panel); | |
| }, true); | |
| } | |
| if (strip.querySelector('.fmn-tab')) return; | |
| const tab = document.createElement('label'); | |
| tab.className = 'v-PushSelect-option fmn-tab'; | |
| tab.textContent = 'Notes'; | |
| tab.addEventListener('click', (ev) => { | |
| ev.preventDefault(); ev.stopPropagation(); | |
| fmnSelectNotesTab(strip, panel); | |
| }); | |
| strip.appendChild(tab); | |
| } | |
| // Mobile FAB/overlay feature gate; off unless explicitly enabled. | |
| function fmnMobileEnabled() { | |
| try { return localStorage.getItem('fmn.enableMobile') === '1'; } | |
| catch (e) { return false; } | |
| } | |
| function setupIntegration(panel) { | |
| injectStyles(); | |
| const apply = () => { | |
| const strip = fmnFindTabStrip(); | |
| if (strip) { fmnRemoveFab(); fmnInjectTab(strip, panel); } | |
| else if (fmnMobileEnabled()) fmnEnsureFab(panel); | |
| else fmnRemoveFab(); | |
| }; | |
| apply(); | |
| let timer = null; | |
| new MutationObserver(() => { | |
| clearTimeout(timer); | |
| timer = setTimeout(apply, 500); | |
| }).observe(document.body, { childList: true, subtree: true }); | |
| } | |
| function fmnEnsureFab(panel) { | |
| if (document.querySelector('.fmn-fab')) return; | |
| const fab = document.createElement('button'); | |
| fab.className = 'fmn-fab'; | |
| fab.title = 'Notes'; | |
| fab.textContent = '✎'; | |
| fab.onclick = () => fmnOpenOverlay(panel); | |
| document.body.appendChild(fab); | |
| } | |
| function fmnRemoveFab() { | |
| const fab = document.querySelector('.fmn-fab'); | |
| if (fab) fab.remove(); | |
| fmnCloseOverlay(); | |
| } | |
| function fmnOpenOverlay(panel) { | |
| if (document.querySelector('.fmn-overlay')) return; | |
| const overlay = document.createElement('div'); | |
| overlay.className = 'fmn-overlay'; | |
| const closeBtn = document.createElement('button'); | |
| closeBtn.className = 'fmn-btn'; | |
| closeBtn.style.cssText = 'position:absolute;top:8px;right:8px;z-index:1'; | |
| closeBtn.textContent = '✕'; | |
| closeBtn.onclick = () => { panel.flushSave(); fmnCloseOverlay(); }; | |
| overlay.append(panel.el, closeBtn); | |
| panel.el.style.display = ''; | |
| document.body.appendChild(overlay); | |
| panel.open(); | |
| } | |
| function fmnCloseOverlay() { | |
| const overlay = document.querySelector('.fmn-overlay'); | |
| if (!overlay) return; | |
| const panel = overlay.querySelector('.fmn-panel'); | |
| if (panel) panel.remove(); | |
| overlay.remove(); | |
| } | |
| (async function fmnMain() { | |
| try { | |
| const backend = await waitForBackend(); | |
| const accountId = findNotesAccountId(backend.accounts); | |
| const panel = createPanel(makeNotesApi(backend, accountId)); | |
| setupIntegration(panel); | |
| } catch (e) { | |
| console.info('[fmn] init skipped:', e.message); | |
| } | |
| })(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment