Skip to content

Instantly share code, notes, and snippets.

@jhyland87
Last active August 22, 2026 18:19
Show Gist options
  • Select an option

  • Save jhyland87/d7df8ef174fc26674402955b764227a5 to your computer and use it in GitHub Desktop.

Select an option

Save jhyland87/d7df8ef174fc26674402955b764227a5 to your computer and use it in GitHub Desktop.
Maximize Ebay conversation, export as PDF or PNG image

Adds a new context menu to ebay conversations that adds the ability to maximize it, or export it as a PNG or PDF. Screenshot 2026-07-27 at 1 07 45 PM

Example of me exporting an abysmal conversation with a seller about a flawed product: ebay-conversation (1)

// ==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'));
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment