Last active
March 30, 2026 09:33
-
-
Save lalibi/0b064f497e5411cdb658c33d9fe510da to your computer and use it in GitHub Desktop.
Gmail Quick Links - Tampermonkey script
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 Gmail Quick Links | |
| // @namespace local.gmail.quicklinks | |
| // @version 0.1.2 | |
| // @description Save current Gmail search/view as quick links in the left menu using Gmail's own sidebar styles, with visual color picker | |
| // @match https://mail.google.com/* | |
| // @updateURL https://gist.githubusercontent.com/lalibi/0b064f497e5411cdb658c33d9fe510da/raw/ | |
| // @downloadURL https://gist.githubusercontent.com/lalibi/0b064f497e5411cdb658c33d9fe510da/raw/ | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=mail.google.com | |
| // @grant GM_getValue | |
| // @grant GM_setValue | |
| // @grant GM_registerMenuCommand | |
| // @run-at document-idle | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| const STORAGE_KEY = 'gmailQuickLinks'; | |
| const ROOT_ID = 'tm-gql-root'; | |
| const STYLE_ID = 'tm-gql-style'; | |
| const PALETTE_ID = 'tm-gql-palette'; | |
| const QUICK_LINK_COLORS = [ | |
| { name: 'Default', value: '' }, | |
| { name: 'Blue', value: '#4986e7' }, | |
| { name: 'Green', value: '#16a765' }, | |
| { name: 'Red', value: '#fb4c2f' }, | |
| { name: 'Yellow', value: '#fce8b3' }, | |
| { name: 'Orange', value: '#ffad47' }, | |
| { name: 'Teal', value: '#2da2bb' }, | |
| { name: 'Purple', value: '#a479e2' }, | |
| { name: 'Pink', value: '#f691b2' }, | |
| { name: 'Mint', value: '#42d692' }, | |
| { name: 'Navy', value: '#0d3472' }, | |
| { name: 'Rose', value: '#fbd3e0' } | |
| ]; | |
| let renderTimer = null; | |
| let lastUrl = location.href; | |
| let paletteCleanup = null; | |
| function log(...args) { | |
| console.log('[GQL]', ...args); | |
| } | |
| async function getLinks() { | |
| const links = await GM_getValue(STORAGE_KEY, []); | |
| return Array.isArray(links) ? links : []; | |
| } | |
| async function setLinks(links) { | |
| await GM_setValue(STORAGE_KEY, links); | |
| } | |
| function getSearchBoxValue() { | |
| const el = | |
| document.querySelector('input[aria-label*="Search"]') || | |
| document.querySelector('input[placeholder*="Search"]') || | |
| document.querySelector('input[name="q"]'); | |
| return el?.value?.trim() || ''; | |
| } | |
| function defaultNameFromLocation() { | |
| const q = getSearchBoxValue(); | |
| if (q) return q; | |
| const hash = location.hash || ''; | |
| const simple = [ | |
| ['#inbox', 'Inbox'], | |
| ['#starred', 'Starred'], | |
| ['#snoozed', 'Snoozed'], | |
| ['#sent', 'Sent'], | |
| ['#drafts', 'Drafts'], | |
| ['#trash', 'Trash'], | |
| ['#spam', 'Spam'], | |
| ['#all', 'All Mail'], | |
| ['#imp', 'Important'] | |
| ]; | |
| for (const [needle, label] of simple) { | |
| if (hash.includes(needle)) return label; | |
| } | |
| const m = hash.match(/#search\/(.+)$/); | |
| if (m) { | |
| try { | |
| return decodeURIComponent(m[1].replace(/\+/g, ' ')); | |
| } catch {} | |
| } | |
| return 'Saved view'; | |
| } | |
| function closePalette() { | |
| document.getElementById(PALETTE_ID)?.remove(); | |
| if (paletteCleanup) { | |
| paletteCleanup(); | |
| paletteCleanup = null; | |
| } | |
| } | |
| function openColorPalette(anchorEl, currentColor = '') { | |
| closePalette(); | |
| return new Promise((resolve) => { | |
| const palette = document.createElement('div'); | |
| palette.id = PALETTE_ID; | |
| palette.innerHTML = ` | |
| <div class="tm-gql-palette-inner"> | |
| ${QUICK_LINK_COLORS.map((c, i) => ` | |
| <button | |
| type="button" | |
| class="tm-gql-swatch ${c.value === currentColor ? 'is-selected' : ''}" | |
| data-index="${i}" | |
| title="${c.name}" | |
| aria-label="${c.name}" | |
| > | |
| <span class="tm-gql-swatch-color" style="${c.value ? `background:${c.value};` : ''}"></span> | |
| </button> | |
| `).join('')} | |
| </div> | |
| `; | |
| document.body.appendChild(palette); | |
| const rect = anchorEl.getBoundingClientRect(); | |
| const top = window.scrollY + rect.bottom + 6; | |
| const left = window.scrollX + Math.max(8, rect.left); | |
| palette.style.top = `${top}px`; | |
| palette.style.left = `${left}px`; | |
| function finish(value) { | |
| closePalette(); | |
| resolve(value); | |
| } | |
| palette.querySelectorAll('.tm-gql-swatch').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| const idx = Number(btn.dataset.index); | |
| finish(QUICK_LINK_COLORS[idx]?.value ?? ''); | |
| }); | |
| }); | |
| const onDocClick = (e) => { | |
| if (!palette.contains(e.target) && e.target !== anchorEl) { | |
| finish(currentColor); | |
| } | |
| }; | |
| const onKey = (e) => { | |
| if (e.key === 'Escape') { | |
| finish(currentColor); | |
| } | |
| }; | |
| setTimeout(() => { | |
| document.addEventListener('mousedown', onDocClick, true); | |
| document.addEventListener('keydown', onKey, true); | |
| }, 0); | |
| paletteCleanup = () => { | |
| document.removeEventListener('mousedown', onDocClick, true); | |
| document.removeEventListener('keydown', onKey, true); | |
| }; | |
| }); | |
| } | |
| async function addCurrentViewFromButton(buttonEl) { | |
| const title = prompt('Quick link name:', defaultNameFromLocation()); | |
| if (!title || !title.trim()) return; | |
| const color = await openColorPalette(buttonEl, ''); | |
| const url = location.href; | |
| const links = await getLinks(); | |
| const idx = links.findIndex(x => x.url === url); | |
| if (idx >= 0) { | |
| links[idx].title = title.trim(); | |
| links[idx].color = color || ''; | |
| } else { | |
| links.push({ | |
| id: crypto.randomUUID(), | |
| title: title.trim(), | |
| url, | |
| color: color || '', | |
| createdAt: Date.now() | |
| }); | |
| } | |
| await setLinks(links); | |
| await render(); | |
| } | |
| async function deleteLink(id) { | |
| const links = await getLinks(); | |
| await setLinks(links.filter(x => x.id !== id)); | |
| await render(); | |
| } | |
| async function updateLinkColor(id, color) { | |
| const links = await getLinks(); | |
| const link = links.find(x => x.id === id); | |
| if (!link) return; | |
| link.color = color || ''; | |
| await setLinks(links); | |
| await render(); | |
| } | |
| function injectStyles() { | |
| if (document.getElementById(STYLE_ID)) return; | |
| const style = document.createElement('style'); | |
| style.id = STYLE_ID; | |
| style.textContent = ` | |
| #${ROOT_ID}[data-gql-ready="1"] .tm-gql-delete { | |
| opacity: 0; | |
| transition: opacity .12s ease, background-color .12s ease; | |
| } | |
| #${ROOT_ID}[data-gql-ready="1"] .tm-gql-row:hover .tm-gql-delete { | |
| opacity: 1; | |
| } | |
| #${ROOT_ID} .tm-gql-plain-btn { | |
| appearance: none; | |
| -webkit-appearance: none; | |
| border: 0; | |
| background: transparent; | |
| box-shadow: none; | |
| outline: none; | |
| cursor: pointer; | |
| color: inherit; | |
| font: inherit; | |
| line-height: 1; | |
| border-radius: 999px; | |
| } | |
| #${ROOT_ID} .tm-gql-plain-btn::before, | |
| #${ROOT_ID} .tm-gql-plain-btn::after { | |
| content: none !important; | |
| display: none !important; | |
| } | |
| #${ROOT_ID} .tm-gql-plus-btn { | |
| width: 24px; | |
| height: 24px; | |
| padding: 0; | |
| font-size: 22px; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| #${ROOT_ID} .tm-gql-delete { | |
| width: 24px; | |
| height: 24px; | |
| padding: 0; | |
| font-size: 16px; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| #${ROOT_ID} .tm-gql-plain-btn:hover { | |
| background: rgba(60,64,67,.08); | |
| } | |
| #${ROOT_ID} .tm-gql-plain-btn:focus, | |
| #${ROOT_ID} .tm-gql-plain-btn:active { | |
| outline: none; | |
| box-shadow: none; | |
| } | |
| #${ROOT_ID} .tm-gql-empty { | |
| opacity: .75; | |
| } | |
| #${ROOT_ID} .tm-gql-color-btn { | |
| border: 0; | |
| background: transparent; | |
| padding: 0; | |
| margin: 0; | |
| cursor: pointer; | |
| display: inline-flex; | |
| align-items: center; | |
| } | |
| #${ROOT_ID} .tm-gql-color-btn:focus { | |
| outline: none; | |
| } | |
| #${ROOT_ID} .tm-gql-color-btn .qj { | |
| cursor: pointer; | |
| } | |
| #${PALETTE_ID} { | |
| position: absolute; | |
| z-index: 2147483647; | |
| background: #fff; | |
| border: 1px solid rgba(0,0,0,.12); | |
| border-radius: 12px; | |
| box-shadow: 0 4px 16px rgba(0,0,0,.2); | |
| padding: 8px; | |
| } | |
| #${PALETTE_ID} .tm-gql-palette-inner { | |
| display: grid; | |
| grid-template-columns: repeat(4, 28px); | |
| gap: 8px; | |
| } | |
| #${PALETTE_ID} .tm-gql-swatch { | |
| width: 28px; | |
| height: 28px; | |
| border-radius: 999px; | |
| border: 0; | |
| background: transparent; | |
| padding: 0; | |
| cursor: pointer; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| #${PALETTE_ID} .tm-gql-swatch:hover { | |
| background: rgba(60,64,67,.08); | |
| } | |
| #${PALETTE_ID} .tm-gql-swatch.is-selected { | |
| box-shadow: inset 0 0 0 2px #1a73e8; | |
| } | |
| #${PALETTE_ID} .tm-gql-swatch-color { | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 4px; | |
| background: #5f6368; | |
| box-shadow: inset 0 0 0 1px rgba(0,0,0,.12); | |
| } | |
| #${PALETTE_ID} .tm-gql-swatch:first-child .tm-gql-swatch-color { | |
| background: | |
| linear-gradient(135deg, transparent 42%, #5f6368 42%, #5f6368 58%, transparent 58%), | |
| #fff; | |
| border: 1px solid rgba(0,0,0,.18); | |
| box-sizing: border-box; | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| } | |
| function findMainMenuSection() { | |
| return document.querySelector('.pp > div .nM'); | |
| } | |
| function findInsertionPoint() { | |
| const mainMenu = findMainMenuSection(); | |
| if (!mainMenu) return null; | |
| const firstYJ = mainMenu.querySelector(':scope > .yJ'); | |
| return { parent: mainMenu, before: firstYJ || null }; | |
| } | |
| function ensureRoot() { | |
| const point = findInsertionPoint(); | |
| if (!point) return null; | |
| let root = document.getElementById(ROOT_ID); | |
| if (root && root.parentElement !== point.parent) { | |
| root.remove(); | |
| root = null; | |
| } | |
| if (!root) { | |
| root = document.createElement('div'); | |
| root.id = ROOT_ID; | |
| } | |
| if (point.before) { | |
| if (root.parentElement !== point.parent || root.nextElementSibling !== point.before) { | |
| point.parent.insertBefore(root, point.before); | |
| } | |
| } else if (root.parentElement !== point.parent) { | |
| point.parent.appendChild(root); | |
| } | |
| return root; | |
| } | |
| function stripIds(node) { | |
| if (!(node instanceof Element)) return; | |
| node.removeAttribute('id'); | |
| node.removeAttribute('aria-labelledby'); | |
| node.removeAttribute('aria-describedby'); | |
| node.querySelectorAll('[id]').forEach(el => el.removeAttribute('id')); | |
| } | |
| function clearEventAttrs(node) { | |
| if (!(node instanceof Element)) return; | |
| const attrs = [ | |
| 'jscontroller', 'jsaction', 'jsname', 'jslog', 'data-tooltip', | |
| 'data-tooltip-align', 'gh', 'draggable', 'aria-haspopup' | |
| ]; | |
| for (const attr of attrs) { | |
| node.removeAttribute(attr); | |
| } | |
| node.querySelectorAll('*').forEach(el => { | |
| for (const attr of attrs) el.removeAttribute(attr); | |
| }); | |
| } | |
| function findNativeHeaderTemplate() { | |
| return document.querySelector('.aAw.FgKVne'); | |
| } | |
| function findNativeRowTemplate() { | |
| return ( | |
| document.querySelector('.zw .TK .aim .TO') || | |
| document.querySelector('.byl .TK .aim .TO') || | |
| document.querySelector('.TK .aim .TO') | |
| ); | |
| } | |
| function cloneHeaderTemplate() { | |
| const tpl = findNativeHeaderTemplate(); | |
| if (!tpl) return null; | |
| const header = tpl.cloneNode(true); | |
| stripIds(header); | |
| clearEventAttrs(header); | |
| const heading = header.querySelector('[role="heading"], .aAv'); | |
| if (heading) { | |
| heading.textContent = 'Quick Links'; | |
| } | |
| const oldPlus = header.querySelector('[role="button"], .aAu'); | |
| if (oldPlus) { | |
| const plus = document.createElement('button'); | |
| plus.type = 'button'; | |
| plus.className = oldPlus.className + ' tm-gql-plain-btn tm-gql-plus-btn'; | |
| plus.title = 'Save current Gmail view'; | |
| plus.setAttribute('aria-label', 'Save current Gmail view'); | |
| plus.textContent = '+'; | |
| plus.addEventListener('click', async (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| await addCurrentViewFromButton(plus); | |
| }); | |
| oldPlus.replaceWith(plus); | |
| } | |
| return header; | |
| } | |
| function applyChipColor(chip, color) { | |
| chip.className = 'qj aEe'; | |
| if (color) { | |
| chip.style.backgroundColor = color; | |
| } else { | |
| chip.classList.add('qr'); | |
| chip.style.backgroundColor = ''; | |
| } | |
| } | |
| function decorateAsQuickLinkRow(row, { id, title, url, color }) { | |
| row.classList.add('tm-gql-row'); | |
| stripIds(row); | |
| clearEventAttrs(row); | |
| const titleLink = row.querySelector('a'); | |
| if (!titleLink) return null; | |
| titleLink.href = url; | |
| titleLink.target = '_top'; | |
| titleLink.textContent = title; | |
| titleLink.setAttribute('aria-label', title); | |
| titleLink.removeAttribute('tabindex'); | |
| const unreadCount = row.querySelector('.bsU'); | |
| if (unreadCount) unreadCount.remove(); | |
| row.querySelectorAll('.pM, .p6, .p8, .TH[role="link"]').forEach(el => el.remove()); | |
| const leftGroup = row.querySelector('.TN'); | |
| if (leftGroup) { | |
| leftGroup.removeAttribute('style'); | |
| } | |
| const colorChip = row.querySelector('.qj'); | |
| if (colorChip) { | |
| applyChipColor(colorChip, color || ''); | |
| const colorBtn = document.createElement('button'); | |
| colorBtn.type = 'button'; | |
| colorBtn.className = 'tm-gql-color-btn'; | |
| colorBtn.title = `Change color for ${title}`; | |
| colorBtn.setAttribute('aria-label', `Change color for ${title}`); | |
| colorBtn.addEventListener('click', async (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| const newColor = await openColorPalette(colorBtn, color || ''); | |
| await updateLinkColor(id, newColor); | |
| }); | |
| colorChip.replaceWith(colorBtn); | |
| colorBtn.appendChild(colorChip); | |
| } | |
| const rightArea = row.querySelector('.nL'); | |
| if (rightArea) { | |
| rightArea.innerHTML = ''; | |
| const del = document.createElement('button'); | |
| del.type = 'button'; | |
| del.className = 'tm-gql-delete tm-gql-plain-btn'; | |
| del.title = 'Remove'; | |
| del.setAttribute('aria-label', `Remove ${title}`); | |
| del.textContent = '×'; | |
| del.addEventListener('click', async (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| await deleteLink(id); | |
| }); | |
| rightArea.appendChild(del); | |
| } | |
| row.addEventListener('click', (e) => { | |
| if (e.target.closest('.tm-gql-delete') || e.target.closest('.tm-gql-color-btn')) return; | |
| const a = row.querySelector('a'); | |
| if (a) { | |
| e.preventDefault(); | |
| location.assign(a.href); | |
| } | |
| }); | |
| return row; | |
| } | |
| function cloneRowTemplate(data) { | |
| const tpl = findNativeRowTemplate(); | |
| if (!tpl) return null; | |
| const aim = tpl.closest('.aim'); | |
| const row = (aim || tpl).cloneNode(true); | |
| return decorateAsQuickLinkRow(row, data); | |
| } | |
| function buildEmptyRow() { | |
| const tpl = findNativeRowTemplate(); | |
| if (!tpl) return null; | |
| const aim = tpl.closest('.aim'); | |
| const row = (aim || tpl).cloneNode(true); | |
| stripIds(row); | |
| clearEventAttrs(row); | |
| row.querySelectorAll('.pM, .p6, .p8, .TH[role="link"]').forEach(el => el.remove()); | |
| const titleLink = row.querySelector('a'); | |
| if (titleLink) { | |
| titleLink.removeAttribute('href'); | |
| titleLink.textContent = 'No quick links yet'; | |
| titleLink.setAttribute('aria-label', 'No quick links yet'); | |
| } | |
| const unreadCount = row.querySelector('.bsU'); | |
| if (unreadCount) unreadCount.remove(); | |
| const rightArea = row.querySelector('.nL'); | |
| if (rightArea) rightArea.innerHTML = ''; | |
| const colorChip = row.querySelector('.qj'); | |
| if (colorChip) { | |
| applyChipColor(colorChip, ''); | |
| } | |
| row.classList.add('tm-gql-empty'); | |
| return row; | |
| } | |
| function buildListContainerFromTemplate() { | |
| const nativeList = | |
| document.querySelector('.byl .TK') || | |
| document.querySelector('.zw .TK') || | |
| document.querySelector('.TK'); | |
| if (!nativeList) return null; | |
| const list = nativeList.cloneNode(false); | |
| stripIds(list); | |
| clearEventAttrs(list); | |
| return list; | |
| } | |
| function buildSection(links) { | |
| const header = cloneHeaderTemplate(); | |
| const list = buildListContainerFromTemplate(); | |
| if (!header || !list) return null; | |
| const wrapper = document.createElement('div'); | |
| const topSpacer = document.createElement('div'); | |
| topSpacer.className = 'yJ'; | |
| topSpacer.appendChild(header); | |
| const listWrap = document.createElement('div'); | |
| listWrap.className = 'yJ'; | |
| const ajl = document.createElement('div'); | |
| ajl.className = 'ajl aib aZ6'; | |
| const wt = document.createElement('div'); | |
| wt.className = 'wT'; | |
| const n3 = document.createElement('div'); | |
| n3.className = 'n3'; | |
| n3.appendChild(list); | |
| wt.appendChild(n3); | |
| ajl.appendChild(wt); | |
| listWrap.appendChild(ajl); | |
| if (links.length) { | |
| for (const link of links) { | |
| const row = cloneRowTemplate(link); | |
| if (row) list.appendChild(row); | |
| } | |
| } else { | |
| const empty = buildEmptyRow(); | |
| if (empty) list.appendChild(empty); | |
| } | |
| wrapper.appendChild(topSpacer); | |
| wrapper.appendChild(listWrap); | |
| return wrapper; | |
| } | |
| async function render() { | |
| injectStyles(); | |
| const root = ensureRoot(); | |
| if (!root) { | |
| log('Could not find main menu section yet'); | |
| return; | |
| } | |
| const links = await getLinks(); | |
| const section = buildSection(links); | |
| if (!section) { | |
| log('Could not build native-looking section'); | |
| return; | |
| } | |
| root.innerHTML = ''; | |
| root.appendChild(section); | |
| root.setAttribute('data-gql-ready', '1'); | |
| log('Rendered', links.length, 'quick links'); | |
| } | |
| function scheduleRender(delay = 250) { | |
| clearTimeout(renderTimer); | |
| renderTimer = setTimeout(() => { | |
| render().catch(err => console.error('[GQL] render error', err)); | |
| }, delay); | |
| } | |
| function watchDom() { | |
| const mo = new MutationObserver((mutations) => { | |
| for (const m of mutations) { | |
| const target = m.target; | |
| if (target instanceof Element && target.closest(`#${ROOT_ID}`)) { | |
| continue; | |
| } | |
| let onlyInsideRoot = true; | |
| for (const node of m.addedNodes) { | |
| if (!(node instanceof Element)) { | |
| onlyInsideRoot = false; | |
| break; | |
| } | |
| if (!node.closest || !node.closest(`#${ROOT_ID}`)) { | |
| onlyInsideRoot = false; | |
| break; | |
| } | |
| } | |
| if (onlyInsideRoot) continue; | |
| scheduleRender(400); | |
| return; | |
| } | |
| }); | |
| mo.observe(document.documentElement, { | |
| childList: true, | |
| subtree: true | |
| }); | |
| } | |
| function watchUrl() { | |
| setInterval(() => { | |
| if (location.href !== lastUrl) { | |
| lastUrl = location.href; | |
| scheduleRender(150); | |
| } | |
| }, 700); | |
| } | |
| function registerMenu() { | |
| if (typeof GM_registerMenuCommand === 'function') { | |
| GM_registerMenuCommand('Save current Gmail view', async () => { | |
| const anchor = document.querySelector(`#${ROOT_ID} .tm-gql-plus-btn`) || document.body; | |
| await addCurrentViewFromButton(anchor); | |
| }); | |
| } | |
| } | |
| function init() { | |
| log('init'); | |
| registerMenu(); | |
| scheduleRender(500); | |
| scheduleRender(1500); | |
| scheduleRender(3000); | |
| watchDom(); | |
| watchUrl(); | |
| } | |
| init(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment