Adds a new context menu to ebay conversations that adds the ability to maximize it, or export it as a PNG or PDF.

Example of me exporting an abysmal conversation with a seller about a flawed product:

| // ==UserScript== | |
| // @name eBay Conversation Expander | |
| // @namespace https://github.com/jhyland87/userscripts | |
| // @version 3.1.1 | |
| // @description Collect a full eBay message thread into a clean full-screen overlay for screenshotting, printing, or exporting as one continuous image. Never modifies the live eBay UI. | |
| // @match https://www.ebay.com/* | |
| // @match https://mesgs.ebay.com/* | |
| // @match https://*.ebay.com/* | |
| // @require https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js | |
| // @grant GM_registerMenuCommand | |
| // @grant GM_xmlhttpRequest | |
| // @connect ebayimg.com | |
| // @run-at document-idle | |
| // @downloadURL https://gist.github.com/jhyland87/d7df8ef174fc26674402955b764227a5/raw/44753fdaba642a9aa9e84f350d6fcfc411a305db/ebay-expand-conversation.user.js | |
| // @updateURL https://gist.github.com/jhyland87/d7df8ef174fc26674402955b764227a5/raw/44753fdaba642a9aa9e84f350d6fcfc411a305db/ebay-expand-conversation.user.js | |
| // @noframes | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| const THREAD_SELECTOR = '[data-testid="messages-thread"]'; | |
| const MSG_SELECTOR = '[data-testid="app-conversation"]'; | |
| const STYLE_ID = 'ebay-expander-style'; | |
| const OVERLAY_CLASS = 'ebay-expander-overlay'; | |
| const wait = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| /** Find the actual scrolling element inside the thread (largest scroller). */ | |
| function findScroller(thread) { | |
| let best = thread; | |
| let bestOverflow = thread.scrollHeight - thread.clientHeight; | |
| for (const el of thread.querySelectorAll('*')) { | |
| const cs = getComputedStyle(el); | |
| const scrolls = cs.overflowY === 'auto' || cs.overflowY === 'scroll'; | |
| const overflow = el.scrollHeight - el.clientHeight; | |
| if (scrolls && overflow > bestOverflow) { | |
| best = el; | |
| bestOverflow = overflow; | |
| } | |
| } | |
| return best; | |
| } | |
| /** | |
| * Scroll the whole thread (loading any lazy history), capturing a clone of | |
| * every message keyed by its aria-label. Returns them ordered top-to-bottom. | |
| */ | |
| async function harvest(thread, onProgress) { | |
| const scroller = findScroller(thread); | |
| const startScroll = scroller.scrollTop; | |
| const collected = new Map(); | |
| const capture = () => { | |
| const base = scroller.getBoundingClientRect().top; | |
| for (const el of scroller.querySelectorAll(MSG_SELECTOR)) { | |
| const key = el.getAttribute('aria-label') || el.textContent.trim().slice(0, 120); | |
| const y = scroller.scrollTop + el.getBoundingClientRect().top - base; | |
| const prev = collected.get(key); | |
| if (prev) { | |
| prev.y = y; | |
| } else { | |
| collected.set(key, { y, node: el.cloneNode(true) }); | |
| } | |
| } | |
| if (onProgress) onProgress(collected.size); | |
| }; | |
| // 1) Force-load older history: hold at top until nothing new loads. | |
| let lastHeight = -1; | |
| let guard = 0; | |
| scroller.scrollTop = 0; | |
| await wait(350); | |
| while (scroller.scrollHeight !== lastHeight && guard++ < 80) { | |
| lastHeight = scroller.scrollHeight; | |
| capture(); | |
| scroller.scrollTop = 0; | |
| await wait(300); | |
| } | |
| // 2) Sweep top -> bottom, capturing everything that renders. | |
| scroller.scrollTop = 0; | |
| await wait(150); | |
| capture(); | |
| guard = 0; | |
| while (guard++ < 300) { | |
| capture(); | |
| const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2; | |
| if (atBottom) break; | |
| scroller.scrollTop = Math.min( | |
| scroller.scrollTop + scroller.clientHeight * 0.85, | |
| scroller.scrollHeight, | |
| ); | |
| await wait(130); | |
| } | |
| capture(); | |
| scroller.scrollTop = startScroll; // leave the real UI as we found it | |
| return [...collected.values()].sort((a, b) => a.y - b.y).map((v) => v.node); | |
| } | |
| /** | |
| * Rebuild the thread's parent hierarchy (shallow clones, so eBay's CSS still | |
| * applies) around the harvested messages, with all scroll clipping removed. | |
| */ | |
| function buildContent(thread, nodes) { | |
| const realSub = | |
| thread.querySelector('.app-conversations__subcontainer') || | |
| thread.querySelector('[data-testid="app-conversation"]').parentElement; | |
| const unclip = (el) => { | |
| el.style.setProperty('height', 'auto', 'important'); | |
| el.style.setProperty('max-height', 'none', 'important'); | |
| el.style.setProperty('min-height', '0', 'important'); | |
| el.style.setProperty('overflow', 'visible', 'important'); | |
| el.style.setProperty('flex', 'none', 'important'); | |
| el.style.setProperty('flex-direction', 'column', 'important'); | |
| el.style.setProperty('justify-content', 'flex-start', 'important'); | |
| el.style.setProperty('transform', 'none', 'important'); | |
| }; | |
| const sub = realSub.cloneNode(false); | |
| unclip(sub); | |
| for (const n of nodes) { | |
| // "You:" in the aria-label marks a message you sent -> align it right. | |
| const label = n.getAttribute('aria-label') || ''; | |
| n.classList.add(/^you[:\s]/i.test(label) ? 'ebay-sent' : 'ebay-recv'); | |
| sub.appendChild(n); | |
| } | |
| // Wrap upward through the original ancestors up to (and including) thread. | |
| let child = sub; | |
| let p = realSub.parentElement; | |
| while (p) { | |
| const shell = p.cloneNode(false); | |
| unclip(shell); | |
| shell.appendChild(child); | |
| child = shell; | |
| if (p === thread) break; | |
| p = p.parentElement; | |
| } | |
| return child; | |
| } | |
| function injectStyle() { | |
| if (document.getElementById(STYLE_ID)) return; | |
| const style = document.createElement('style'); | |
| style.id = STYLE_ID; | |
| style.textContent = ` | |
| .${OVERLAY_CLASS} { | |
| position: fixed; inset: 0; z-index: 2147483646; | |
| background: #fff; color: #111; overflow: auto; | |
| font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; | |
| } | |
| .${OVERLAY_CLASS}__bar { | |
| position: sticky; top: 0; z-index: 2; | |
| display: flex; gap: 8px; align-items: center; | |
| padding: 10px 16px; background: #fff; | |
| border-bottom: 1px solid rgba(0,0,0,0.12); | |
| } | |
| .${OVERLAY_CLASS}__bar .spacer { flex: 1; } | |
| .${OVERLAY_CLASS}__bar button { | |
| padding: 6px 14px; border-radius: 999px; cursor: pointer; | |
| border: 1px solid #0064d2; background: #0064d2; color: #fff; font: inherit; | |
| } | |
| .${OVERLAY_CLASS}__bar button.secondary { background: #fff; color: #0064d2; } | |
| .${OVERLAY_CLASS}__body { max-width: 860px; margin: 0 auto; padding: 16px 24px 64px; } | |
| .${OVERLAY_CLASS}__status { font: inherit; color: #555; } | |
| /* Rebuild bubble layout the overlay can't inherit from eBay */ | |
| .${OVERLAY_CLASS} .app-conversation { | |
| display: flex !important; flex-direction: column !important; | |
| align-items: flex-start !important; margin: 10px 0 !important; | |
| } | |
| .${OVERLAY_CLASS} .app-conversation.ebay-sent { align-items: flex-end !important; } | |
| /* Received: avatar sits to the left of the bubble, bottom-aligned */ | |
| .${OVERLAY_CLASS} .app-conversation__message-bubble { | |
| max-width: 70% !important; | |
| display: flex !important; flex-direction: row !important; | |
| align-items: flex-end !important; gap: 8px !important; | |
| } | |
| /* Sent timestamps go under the bubble on the right */ | |
| .${OVERLAY_CLASS} .app-conversation.ebay-sent .app-conversation__message-bubble__posted-time { | |
| text-align: right !important; | |
| } | |
| .${OVERLAY_CLASS} .app-conversation__message-bubble__posted-time { | |
| margin-top: 4px !important; color: #767676 !important; font-size: 12px !important; | |
| } | |
| /* Text bubble fill: grey for sent, bordered white for received (images stay bare) */ | |
| .${OVERLAY_CLASS} .app-conversation__message-bubble__message__content { | |
| display: inline-block !important; padding: 10px 14px !important; | |
| border-radius: 14px !important; | |
| } | |
| .${OVERLAY_CLASS} .app-conversation.ebay-sent .app-conversation__message-bubble__message__content { | |
| background: #eceff1 !important; | |
| } | |
| .${OVERLAY_CLASS} .app-conversation.ebay-recv .app-conversation__message-bubble__message__content { | |
| background: #fff !important; border: 1px solid #e2e2e2 !important; | |
| } | |
| /* Preserve the sender's line breaks: each segment is its own line */ | |
| .${OVERLAY_CLASS} .app-conversation__message-bubble__message__content [data-testid="ux-textual-display"] { | |
| display: block !important; | |
| } | |
| .${OVERLAY_CLASS} .app-conversation__message-bubble__message__content .clipped { | |
| display: none !important; | |
| } | |
| /* Blank lines: eBay marks a double-Enter with an empty separator span */ | |
| .${OVERLAY_CLASS} .app-conversation__message-bubble__message__content > .ux-textspans { | |
| display: block !important; min-height: 1em !important; | |
| } | |
| .${OVERLAY_CLASS} .app-conversation__message-separator { | |
| align-self: center !important; width: 100% !important; text-align: center !important; | |
| } | |
| /* Keep attachment images to a sane size instead of full natural resolution */ | |
| .${OVERLAY_CLASS} img { max-width: 100% !important; height: auto !important; } | |
| .${OVERLAY_CLASS} .app-conversation__message-bubble__message__attachments img { | |
| max-width: 360px !important; border-radius: 8px !important; | |
| } | |
| .${OVERLAY_CLASS} .app-conversation__message-bubble__avatar img { max-width: 40px !important; } | |
| /* Custom right-click menu on the live thread */ | |
| .ebay-expander-menu { | |
| position: fixed; z-index: 2147483647; min-width: 190px; | |
| padding: 4px; margin: 0; list-style: none; | |
| background: #fff; color: #111; border: 1px solid rgba(0,0,0,0.15); | |
| border-radius: 8px; box-shadow: 0 6px 24px rgba(0,0,0,0.22); | |
| font: 14px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; | |
| user-select: none; | |
| } | |
| .ebay-expander-menu li { padding: 8px 12px; border-radius: 5px; cursor: pointer; white-space: nowrap; } | |
| .ebay-expander-menu li:hover { background: #0064d2; color: #fff; } | |
| /* Print: show ONLY the overlay, fully expanded */ | |
| @media print { | |
| html.ebay-overlay-open body > *:not(.${OVERLAY_CLASS}) { display: none !important; } | |
| html.ebay-overlay-open .${OVERLAY_CLASS} { position: static !important; overflow: visible !important; height: auto !important; } | |
| html.ebay-overlay-open .${OVERLAY_CLASS}__bar { display: none !important; } | |
| .ebay-expander-menu { display: none !important; } | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| } | |
| /** Fetch a URL through the userscript bridge (bypasses CORS) as a data URI. */ | |
| function fetchDataUrl(url) { | |
| return new Promise((resolve) => { | |
| if (typeof GM_xmlhttpRequest !== 'function') { | |
| resolve(null); | |
| return; | |
| } | |
| GM_xmlhttpRequest({ | |
| method: 'GET', | |
| url, | |
| responseType: 'blob', | |
| onload: (res) => { | |
| const reader = new FileReader(); | |
| reader.onloadend = () => resolve(reader.result); | |
| reader.onerror = () => resolve(null); | |
| reader.readAsDataURL(res.response); | |
| }, | |
| onerror: () => resolve(null), | |
| }); | |
| }); | |
| } | |
| /** Replace every cross-origin <img> in root with an inlined data URI. */ | |
| async function inlineImages(root) { | |
| const imgs = [...root.querySelectorAll('img')]; | |
| await Promise.all( | |
| imgs.map(async (img) => { | |
| const src = img.currentSrc || img.src; | |
| if (!src || src.startsWith('data:')) return; | |
| const dataUrl = await fetchDataUrl(src); | |
| if (dataUrl) { | |
| img.removeAttribute('srcset'); | |
| img.src = dataUrl; | |
| } | |
| }), | |
| ); | |
| // Wait for the swapped-in images to decode before rasterizing. | |
| await Promise.all( | |
| [...root.querySelectorAll('img')].map((img) => | |
| img.decode ? img.decode().catch(() => {}) : Promise.resolve(), | |
| ), | |
| ); | |
| } | |
| /** Render the conversation body to a single tall PNG and download it. */ | |
| async function exportImage(body, statusEl) { | |
| if (typeof html2canvas !== 'function') { | |
| alert('eBay Expander: image library missing. Reinstall the script so the @require line loads.'); | |
| return; | |
| } | |
| if (statusEl) statusEl.textContent = 'Inlining images…'; | |
| await inlineImages(body); | |
| if (statusEl) statusEl.textContent = 'Rendering image…'; | |
| // Capture from the top so the whole thread is in view for html2canvas. | |
| const scrollParent = body.parentElement; | |
| if (scrollParent) scrollParent.scrollTop = 0; | |
| // Pass the element's FULL size as the render window — otherwise html2canvas | |
| // clips to the viewport height and only the top strip is captured. | |
| const width = body.scrollWidth; | |
| const height = body.scrollHeight; | |
| // Stay under the browser canvas-height ceiling (~32k px) on long threads. | |
| const scale = Math.min(2, Math.max(1, Math.floor(30000 / Math.max(height, 1)))); | |
| const canvas = await html2canvas(body, { | |
| backgroundColor: '#ffffff', | |
| scale, | |
| useCORS: true, | |
| logging: false, | |
| width, | |
| height, | |
| windowWidth: width, | |
| windowHeight: height, | |
| scrollX: 0, | |
| scrollY: 0, | |
| }); | |
| await new Promise((resolve) => { | |
| canvas.toBlob((blob) => { | |
| if (!blob) { | |
| alert('eBay Expander: image export failed.'); | |
| resolve(); | |
| return; | |
| } | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = 'ebay-conversation.png'; | |
| a.click(); | |
| setTimeout(() => URL.revokeObjectURL(a.href), 10000); | |
| resolve(); | |
| }, 'image/png'); | |
| }); | |
| if (statusEl) statusEl.textContent = 'Image saved'; | |
| } | |
| function closeOverlay() { | |
| const overlay = document.querySelector('.' + OVERLAY_CLASS); | |
| if (overlay) overlay.remove(); | |
| document.documentElement.classList.remove('ebay-overlay-open'); | |
| } | |
| /** | |
| * Open the overlay: harvest the thread, then render it clean and full-screen. | |
| * @param mode Optional follow-up action: 'print' or 'image'. | |
| */ | |
| async function openOverlay(mode) { | |
| const thread = document.querySelector(THREAD_SELECTOR); | |
| if (!thread) { | |
| alert('eBay Expander: open a conversation first — no message thread found on this page.'); | |
| return; | |
| } | |
| injectStyle(); | |
| closeOverlay(); | |
| const overlay = document.createElement('div'); | |
| overlay.className = OVERLAY_CLASS; | |
| const bar = document.createElement('div'); | |
| bar.className = OVERLAY_CLASS + '__bar'; | |
| const status = document.createElement('span'); | |
| status.className = OVERLAY_CLASS + '__status'; | |
| status.textContent = 'Collecting messages…'; | |
| const spacer = document.createElement('span'); | |
| spacer.className = 'spacer'; | |
| const imageBtn = document.createElement('button'); | |
| imageBtn.textContent = 'Save as image'; | |
| const printBtn = document.createElement('button'); | |
| printBtn.className = 'secondary'; | |
| printBtn.textContent = 'Print / Save PDF'; | |
| printBtn.addEventListener('click', () => window.print()); | |
| const closeBtn = document.createElement('button'); | |
| closeBtn.className = 'secondary'; | |
| closeBtn.textContent = 'Close (Esc)'; | |
| closeBtn.addEventListener('click', closeOverlay); | |
| bar.append(status, spacer, imageBtn, printBtn, closeBtn); | |
| const body = document.createElement('div'); | |
| body.className = OVERLAY_CLASS + '__body'; | |
| imageBtn.addEventListener('click', async () => { | |
| const original = status.textContent; | |
| imageBtn.disabled = true; | |
| try { | |
| await exportImage(body, status); | |
| } finally { | |
| imageBtn.disabled = false; | |
| setTimeout(() => { | |
| status.textContent = original; | |
| }, 2500); | |
| } | |
| }); | |
| overlay.append(bar, body); | |
| document.body.appendChild(overlay); | |
| document.documentElement.classList.add('ebay-overlay-open'); | |
| const nodes = await harvest(thread, (n) => { | |
| status.textContent = `Collecting messages… (${n})`; | |
| }); | |
| body.appendChild(buildContent(thread, nodes)); | |
| status.textContent = `${nodes.length} message${nodes.length === 1 ? '' : 's'}`; | |
| overlay.scrollTop = 0; | |
| if (mode === 'print') { | |
| await wait(500); | |
| window.print(); | |
| } else if (mode === 'image') { | |
| imageBtn.click(); | |
| } | |
| } | |
| // ---- Custom right-click menu on the live conversation ---- | |
| function closeMenu() { | |
| const m = document.querySelector('.ebay-expander-menu'); | |
| if (m) m.remove(); | |
| } | |
| function showMenu(x, y) { | |
| closeMenu(); | |
| injectStyle(); | |
| const menu = document.createElement('ul'); | |
| menu.className = 'ebay-expander-menu'; | |
| const items = [ | |
| { label: 'Full screen convo', action: () => openOverlay() }, | |
| { label: 'Save as image', action: () => openOverlay('image') }, | |
| { label: 'Print / Save PDF', action: () => openOverlay('print') }, | |
| ]; | |
| for (const item of items) { | |
| const li = document.createElement('li'); | |
| li.textContent = item.label; | |
| li.addEventListener('click', () => { | |
| closeMenu(); | |
| item.action(); | |
| }); | |
| menu.appendChild(li); | |
| } | |
| menu.style.visibility = 'hidden'; | |
| document.body.appendChild(menu); | |
| const rect = menu.getBoundingClientRect(); | |
| menu.style.left = Math.max(6, Math.min(x, window.innerWidth - rect.width - 6)) + 'px'; | |
| menu.style.top = Math.max(6, Math.min(y, window.innerHeight - rect.height - 6)) + 'px'; | |
| menu.style.visibility = 'visible'; | |
| } | |
| document.addEventListener('contextmenu', (e) => { | |
| const inThread = e.target.closest && e.target.closest(THREAD_SELECTOR); | |
| if (!inThread) return; | |
| e.preventDefault(); | |
| showMenu(e.clientX, e.clientY); | |
| }); | |
| document.addEventListener('click', (e) => { | |
| if (!e.target.closest || !e.target.closest('.ebay-expander-menu')) closeMenu(); | |
| }); | |
| window.addEventListener('scroll', closeMenu, true); | |
| window.addEventListener('keydown', (e) => { | |
| if (e.key === 'Escape') { | |
| closeMenu(); | |
| closeOverlay(); | |
| } | |
| if (e.altKey && !e.ctrlKey && !e.metaKey && (e.key === 'e' || e.key === 'E')) { | |
| e.preventDefault(); | |
| openOverlay(); | |
| } | |
| }); | |
| if (typeof GM_registerMenuCommand === 'function') { | |
| GM_registerMenuCommand('Full screen convo', () => openOverlay()); | |
| GM_registerMenuCommand('Save conversation as image', () => openOverlay('image')); | |
| GM_registerMenuCommand('Print conversation', () => openOverlay('print')); | |
| } | |
| })(); |