Last active
February 23, 2026 16:22
-
-
Save zurfyx/be68a2b732369d69de3ea3bb5e50389a to your computer and use it in GitHub Desktop.
Beautify Facebook Messages (Messenger) TamperMonkey script -- Note that Messenger.com won't be available after April 14, 2026
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 Facebook -> Messenger Mode (flat + safer sizing) | |
| // @namespace https://zurfyx.local/ | |
| // @version 3.1.1 | |
| // @description Toggle Messenger mode: redirect, hide header, set --header-height=0, force 100vh wrapper chains, remove borders/padding. | |
| // @match https://www.facebook.com/* | |
| // @match https://facebook.com/* | |
| // @run-at document-start | |
| // @grant none | |
| // ==/UserScript== | |
| (() => { | |
| 'use strict'; | |
| const TARGET_MESSAGES = 'https://www.facebook.com/messages'; | |
| const FLAG_KEY = 'tm_fb_messenger_mode'; | |
| const STORAGE = 'session'; // 'session' or 'local' | |
| const store = STORAGE === 'local' ? localStorage : sessionStorage; | |
| // Left pane width clamp (you asked for this too; minimal, no min-width hacks) | |
| const LEFT_PANE_WIDTH = 'clamp(320px, 30vw, 420px)'; | |
| const STYLE_ID = 'tm-fb-messenger-style'; | |
| const BTN_ID = 'tm-fb-messenger-toggle'; | |
| const INLINE_BTN_ID = 'tm-fb-messenger-exit-inline'; | |
| const enabled = () => store.getItem(FLAG_KEY) === '1'; | |
| const setEnabled = (v) => (v ? store.setItem(FLAG_KEY, '1') : store.removeItem(FLAG_KEY)); | |
| const restricted = (p) => | |
| p.startsWith('/login') || p.startsWith('/checkpoint') || p.startsWith('/recover'); | |
| function getMount() { | |
| return document.querySelector('div[id^="mount_"]') || document.body; | |
| } | |
| function findThreadList() { | |
| return ( | |
| document.querySelector('[role="navigation"][aria-label="Thread list"]') || | |
| document.querySelector('[aria-label="Thread list"]') | |
| ); | |
| } | |
| function findConversationMain() { | |
| return document.querySelector('[role="main"]'); | |
| } | |
| function closestCommonAncestor(a, b) { | |
| if (!a || !b) return null; | |
| const seen = new Set(); | |
| let x = a; | |
| for (let i = 0; x && i < 40; i++) { | |
| seen.add(x); | |
| x = x.parentElement; | |
| } | |
| let y = b; | |
| for (let i = 0; y && i < 40; i++) { | |
| if (seen.has(y)) return y; | |
| y = y.parentElement; | |
| } | |
| return null; | |
| } | |
| // Find the split-pane container (flex row) that holds both thread list and main | |
| function findSplitContainer(threadList, main) { | |
| const common = closestCommonAncestor(threadList, main); | |
| if (!common) return null; | |
| // Walk upward a few steps to find a flex row container | |
| let el = common; | |
| for (let i = 0; el && i < 10; i++) { | |
| const cs = getComputedStyle(el); | |
| if (cs.display.includes('flex') && (cs.flexDirection === 'row' || cs.flexDirection === 'row-reverse')) { | |
| return el; | |
| } | |
| el = el.parentElement; | |
| } | |
| return common; | |
| } | |
| // Find direct child of split that contains a node (for width pinning) | |
| function getDirectChildUnder(split, node) { | |
| if (!split || !node) return null; | |
| let el = node; | |
| for (let i = 0; el && i < 60; i++) { | |
| if (el.parentElement === split) return el; | |
| el = el.parentElement; | |
| if (el === split) break; | |
| } | |
| return null; | |
| } | |
| // ---------------- CSS ---------------- | |
| function injectCss() { | |
| if (document.getElementById(STYLE_ID)) return; | |
| const style = document.createElement('style'); | |
| style.id = STYLE_ID; | |
| style.textContent = ` | |
| :root { --header-height: 0px !important; } | |
| body { --header-height: 0px !important; } | |
| html, body { height: 100% !important; min-height: 100% !important; } | |
| body, #facebook, div[id^="mount_"] { background: #fff !important; } | |
| [data-pagelet="TopBar"], | |
| header[role="banner"], | |
| div[role="banner"] { | |
| display: none !important; | |
| height: 0 !important; | |
| min-height: 0 !important; | |
| max-height: 0 !important; | |
| overflow: hidden !important; | |
| } | |
| [role="main"] div, | |
| [aria-label="Thread list"] div { | |
| box-shadow: none !important; | |
| } | |
| `; | |
| document.documentElement.appendChild(style); | |
| } | |
| function pinHeaderVar() { | |
| document.documentElement.style.setProperty('--header-height', '0px', 'important'); | |
| if (document.body) document.body.style.setProperty('--header-height', '0px', 'important'); | |
| } | |
| function hideBannerInline() { | |
| document | |
| .querySelectorAll('[data-pagelet="TopBar"], header[role="banner"], div[role="banner"]') | |
| .forEach((el) => el.style.setProperty('display', 'none', 'important')); | |
| } | |
| // ---------------- Overrides ---------------- | |
| // Exactly what you showed (no min-width!) | |
| const OVERRIDES = [ | |
| ['--header-height', '0px'], | |
| ['height', '100vh'], | |
| ['min-height', '100vh'], | |
| ['max-height', '100%'], | |
| ['padding', '0px'], | |
| ['margin', '0px'], | |
| ['border', 'none'], | |
| ['border-radius', '0px'], | |
| ['box-shadow', 'none'], | |
| ['background', '#fff'], | |
| ['outline', 'none'], | |
| ]; | |
| function applyOverrides(el) { | |
| if (!el || !el.style) return; | |
| for (const [k, v] of OVERRIDES) el.style.setProperty(k, v, 'important'); | |
| // Only apply flex growth if this is a flex *column* container. | |
| // This avoids messing with the split-pane widths (flex row). | |
| const cs = getComputedStyle(el); | |
| if (cs.display.includes('flex') && (cs.flexDirection === 'column' || cs.flexDirection === 'column-reverse')) { | |
| el.style.setProperty('flex', '1 1 auto', 'important'); | |
| // ✅ IMPORTANT FIX: | |
| // DO NOT clobber min-height:100vh with min-height:0px. | |
| // That was undoing your own override and causing the bottom gap chase. | |
| // (If you ever need "min-height:0" for a specific scroll container, we can target it explicitly later.) | |
| } | |
| } | |
| function applyChainUp(anchor, stopAt) { | |
| const mount = stopAt || getMount(); | |
| if (!anchor || !mount) return; | |
| // Ensure mount can stretch but DO NOT set flex/width-related things on it. | |
| mount.style.setProperty('min-height', '100vh', 'important'); | |
| mount.style.setProperty('height', '100%', 'important'); | |
| mount.style.setProperty('background', '#fff', 'important'); | |
| let el = anchor; | |
| for (let i = 0; el && i < 32; i++) { | |
| applyOverrides(el); | |
| if (el === mount) break; | |
| el = el.parentElement; | |
| } | |
| } | |
| // ✅ Apply min-height:100vh to the *specific* descendants you showed: | |
| // "selected wrapper + its children" (2 levels) under each panel. | |
| function apply100vhDown(anchor, levels = 2) { | |
| if (!anchor) return; | |
| let el = anchor; | |
| for (let i = 0; el && i < levels; i++) { | |
| const kids = Array.from(el.children || []); | |
| const next = kids.find(k => /^(DIV|SECTION|MAIN|NAV|ASIDE)$/.test(k.tagName)) || kids[0]; | |
| if (!next) break; | |
| next.style.setProperty('min-height', '100vh', 'important'); | |
| next.style.setProperty('--header-height', '0px', 'important'); | |
| el = next; | |
| } | |
| } | |
| // Fix containers with calc-based max-height (e.g. calc(100vh - 2px - var(--header-height))) | |
| // Only touches elements whose computed max-height is close to but less than viewport height. | |
| function fixRestrictiveMaxHeights() { | |
| const vh = window.innerHeight; | |
| const anchors = [findConversationMain(), findThreadList()]; | |
| for (const anchor of anchors) { | |
| if (!anchor) continue; | |
| let el = anchor; | |
| for (let i = 0; i < 8; i++) { | |
| const kids = Array.from(el.children || []); | |
| const next = kids.find(k => /^(DIV|SECTION|MAIN|NAV|ASIDE)$/.test(k.tagName)) || kids[0]; | |
| if (!next) break; | |
| const mh = parseFloat(getComputedStyle(next).maxHeight); | |
| if (Number.isFinite(mh) && mh > vh * 0.8 && mh < vh) { | |
| next.style.setProperty('max-height', '100vh', 'important'); | |
| next.style.setProperty('min-height', '100vh', 'important'); | |
| } | |
| el = next; | |
| } | |
| } | |
| } | |
| // Force a reasonable left pane width (no min-width) | |
| function pinLeftPaneWidth(split, threadList, main) { | |
| if (!split || !threadList) return; | |
| const leftPane = getDirectChildUnder(split, threadList); | |
| if (!leftPane) return; | |
| leftPane.style.setProperty('flex', `0 0 ${LEFT_PANE_WIDTH}`, 'important'); | |
| leftPane.style.setProperty('width', LEFT_PANE_WIDTH, 'important'); | |
| leftPane.style.setProperty('max-width', LEFT_PANE_WIDTH, 'important'); | |
| // Ensure right side can flex without causing weird width swings | |
| const rightPane = getDirectChildUnder(split, main); | |
| if (rightPane) rightPane.style.setProperty('flex', '1 1 auto', 'important'); | |
| } | |
| // Kill 1px dividers + wrapper padding, but ONLY on large layout wrappers (not bubbles) | |
| function flattenSplitPaneChrome(split) { | |
| if (!split) return; | |
| // include split + a bounded subtree scan | |
| const nodes = split.querySelectorAll('div, section, nav, main, aside'); | |
| const cap = Math.min(nodes.length, 1200); | |
| for (let i = 0; i < cap; i++) { | |
| const el = nodes[i]; | |
| const cs = getComputedStyle(el); | |
| // Only touch large wrappers | |
| const big = el.clientHeight > 200 && el.clientWidth > 240; | |
| if (!big) continue; | |
| // Borders (common split divider) | |
| const bw = | |
| (parseFloat(cs.borderLeftWidth) || 0) + | |
| (parseFloat(cs.borderRightWidth) || 0) + | |
| (parseFloat(cs.borderTopWidth) || 0) + | |
| (parseFloat(cs.borderBottomWidth) || 0); | |
| // Padding (wrapper gutters) | |
| const pad = | |
| (parseFloat(cs.paddingLeft) || 0) + | |
| (parseFloat(cs.paddingRight) || 0) + | |
| (parseFloat(cs.paddingTop) || 0) + | |
| (parseFloat(cs.paddingBottom) || 0); | |
| // Shadows | |
| const hasShadow = cs.boxShadow && cs.boxShadow !== 'none'; | |
| if (bw > 0) el.style.setProperty('border', 'none', 'important'); | |
| if (pad >= 8) el.style.setProperty('padding', '0px', 'important'); | |
| if (hasShadow) el.style.setProperty('box-shadow', 'none', 'important'); | |
| // Force white background to blend flat | |
| if (cs.backgroundColor !== 'rgba(0, 0, 0, 0)') { | |
| el.style.setProperty('background', '#fff', 'important'); | |
| } | |
| } | |
| } | |
| // Hide the tablist tabs above the chat list, keep search and header. | |
| function hideThreadListChrome() { | |
| const nav = findThreadList(); | |
| if (!nav) return; | |
| nav.querySelectorAll('[role="tablist"]').forEach(tl => { | |
| // Walk up to hide the largest container that only holds this tablist, | |
| // stopping before we reach one that also contains the search or header | |
| let el = tl; | |
| while (el.parentElement && el.parentElement !== nav) { | |
| const p = el.parentElement; | |
| if (p.querySelector('[aria-label="Search Messenger"], [aria-label="Settings, help and more"], [aria-label="New message"]')) break; | |
| el = p; | |
| } | |
| el.style.setProperty('display', 'none', 'important'); | |
| }); | |
| } | |
| // FB margin hack becomes pathological at header-height 0 | |
| function neutralizeVhMarginHack() { | |
| const root = getMount(); | |
| if (!root) return; | |
| const approxMinusVh = -window.innerHeight; | |
| const nodes = root.querySelectorAll('div'); | |
| for (let i = 0; i < nodes.length && i < 1600; i++) { | |
| const el = nodes[i]; | |
| const cs = getComputedStyle(el); | |
| const mb = parseFloat(cs.marginBottom || '0'); | |
| if (!Number.isFinite(mb)) continue; | |
| const looksLikeHack = mb < approxMinusVh * 0.75 && Math.abs(mb - approxMinusVh) < 180; | |
| if (!looksLikeHack) continue; | |
| el.style.setProperty('margin-bottom', '0px', 'important'); | |
| applyOverrides(el); | |
| break; | |
| } | |
| } | |
| // ---------------- Main apply ---------------- | |
| function applyMessengerMode() { | |
| if (!enabled()) return; | |
| if (!location.pathname.startsWith('/messages')) return; | |
| injectCss(); | |
| pinHeaderVar(); | |
| hideBannerInline(); | |
| const mount = getMount(); | |
| const threadList = findThreadList(); | |
| const main = findConversationMain(); | |
| const split = findSplitContainer(threadList, main); | |
| // Replicate your override style chains for both panels | |
| applyChainUp(threadList, mount); | |
| applyChainUp(main, mount); | |
| // ✅ Apply 100vh to the specific descendants you showed (selected + its children) | |
| // Do it for both panes. | |
| apply100vhDown(threadList); | |
| apply100vhDown(main); | |
| // Left pane width clamp | |
| pinLeftPaneWidth(split, threadList, main); | |
| // Flatten the split pane chrome (borders/padding/shadows) | |
| flattenSplitPaneChrome(split); | |
| // Fix the calc(-100vh + var(--header-height)) issue at header-height 0 | |
| neutralizeVhMarginHack(); | |
| // Fix calc-based max-height gaps | |
| fixRestrictiveMaxHeights(); | |
| // Hide search/tabs chrome above chat list | |
| hideThreadListChrome(); | |
| // Place exit button inline in menu bar | |
| ensureInlineExitButton(); | |
| } | |
| // ---------------- SPA scheduling ---------------- | |
| let raf = false; | |
| function schedule() { | |
| if (raf) return; | |
| raf = true; | |
| requestAnimationFrame(() => { | |
| raf = false; | |
| applyMessengerMode(); | |
| }); | |
| } | |
| let observer = null; | |
| function ensureObserver() { | |
| if (observer) return; | |
| observer = new MutationObserver(schedule); | |
| observer.observe(document.documentElement, { subtree: true, childList: true }); | |
| } | |
| function applyIfNeeded() { | |
| if (!enabled()) return; | |
| if (restricted(location.pathname)) return; | |
| if (!location.pathname.startsWith('/messages')) { | |
| location.assign(TARGET_MESSAGES); | |
| return; | |
| } | |
| applyMessengerMode(); | |
| ensureObserver(); | |
| } | |
| // ---------------- Inline exit button (inside menu bar) ---------------- | |
| function ensureInlineExitButton() { | |
| if (!enabled()) return; | |
| if (!location.pathname.startsWith('/messages')) return; | |
| if (document.getElementById(INLINE_BTN_ID)) return; | |
| const settingsBtn = document.querySelector('[aria-label="Settings, help and more"]'); | |
| if (!settingsBtn) return; | |
| // Walk up to the div.x1diwwjn wrapper that contains the settings button | |
| let wrapper = settingsBtn; | |
| for (let i = 0; i < 5; i++) { | |
| if (!wrapper.parentElement) break; | |
| wrapper = wrapper.parentElement; | |
| if (wrapper.tagName === 'DIV' && wrapper.parentElement && wrapper.parentElement.children.length > 1) break; | |
| } | |
| const container = wrapper.parentElement; | |
| if (!container) return; | |
| const btn = document.createElement('div'); | |
| btn.id = INLINE_BTN_ID; | |
| btn.role = 'button'; | |
| btn.tabIndex = 0; | |
| btn.title = 'Exit Messenger mode'; | |
| // Use an "X" SVG icon to match the other circular icon buttons | |
| btn.innerHTML = `<svg viewBox="0 0 20 20" width="20" height="20" fill="currentColor"><path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22z"/></svg>`; | |
| // Copy dimensions and shape from the settings button | |
| const settingsCs = getComputedStyle(settingsBtn); | |
| btn.style.cssText = ` | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: ${settingsCs.width}; | |
| height: ${settingsCs.height}; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| background: transparent; | |
| border: none; | |
| color: ${settingsCs.color}; | |
| user-select: none; | |
| `; | |
| btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(0,0,0,0.05)'; }); | |
| btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; }); | |
| btn.addEventListener('mousedown', () => { btn.style.background = 'rgba(0,0,0,0.1)'; }); | |
| btn.addEventListener('mouseup', () => { btn.style.background = 'rgba(0,0,0,0.05)'; }); | |
| btn.addEventListener('click', () => { | |
| setEnabled(false); | |
| location.assign('https://www.facebook.com/'); | |
| }); | |
| // Wrap in a div matching the sibling wrappers | |
| const btnWrapper = document.createElement('div'); | |
| if (wrapper.className) btnWrapper.className = wrapper.className; | |
| btnWrapper.appendChild(btn); | |
| container.insertBefore(btnWrapper, container.firstChild); | |
| // Hide floating button when inline is visible | |
| const floatingBtn = document.getElementById(BTN_ID); | |
| if (floatingBtn) floatingBtn.style.display = 'none'; | |
| } | |
| // ---------------- Toggle button ---------------- | |
| function ensureButton() { | |
| if (document.getElementById(BTN_ID)) return; | |
| const btn = document.createElement('button'); | |
| btn.id = BTN_ID; | |
| btn.type = 'button'; | |
| const refresh = () => { | |
| btn.textContent = enabled() ? 'Exit Messenger mode' : 'Messenger mode'; | |
| // Hide floating button when inline exit is present, or on restricted pages | |
| const inlinePresent = !!document.getElementById(INLINE_BTN_ID); | |
| if (restricted(location.pathname)) { | |
| btn.style.display = 'none'; | |
| } else if (inlinePresent) { | |
| btn.style.display = 'none'; | |
| } else { | |
| btn.style.display = 'block'; | |
| } | |
| }; | |
| btn.style.cssText = ` | |
| position: fixed; | |
| right: 12px; | |
| bottom: 12px; | |
| z-index: 2147483647; | |
| padding: 10px 12px; | |
| border-radius: 999px; | |
| border: 1px solid rgba(0,0,0,.12); | |
| background: rgba(255,255,255,.92); | |
| color: #111; | |
| font: 13px/1 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif; | |
| cursor: pointer; | |
| box-shadow: 0 6px 18px rgba(0,0,0,.12); | |
| backdrop-filter: blur(8px); | |
| `; | |
| btn.addEventListener('click', () => { | |
| if (!enabled()) { | |
| setEnabled(true); | |
| refresh(); | |
| applyIfNeeded(); | |
| } else { | |
| setEnabled(false); | |
| refresh(); | |
| location.assign('https://www.facebook.com/'); | |
| } | |
| }); | |
| document.documentElement.appendChild(btn); | |
| refresh(); | |
| hookSpa(refresh); | |
| } | |
| function hookSpa(onChange) { | |
| const _push = history.pushState; | |
| const _replace = history.replaceState; | |
| if (history.__tm_fb_hooked) return; | |
| history.__tm_fb_hooked = true; | |
| history.pushState = function (...args) { | |
| const ret = _push.apply(this, args); | |
| queueMicrotask(() => { onChange(); applyIfNeeded(); }); | |
| return ret; | |
| }; | |
| history.replaceState = function (...args) { | |
| const ret = _replace.apply(this, args); | |
| queueMicrotask(() => { onChange(); applyIfNeeded(); }); | |
| return ret; | |
| }; | |
| window.addEventListener('popstate', () => { onChange(); applyIfNeeded(); }); | |
| } | |
| // ---------------- Boot ---------------- | |
| applyIfNeeded(); | |
| const startUI = () => ensureButton(); | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', startUI, { once: true }); | |
| } else { | |
| startUI(); | |
| } | |
| window.addEventListener('load', applyIfNeeded); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
