FetLife Suite - Home Feed

Rebuilds the FetLife home feed as a responsive masonry grid (1 to 4 columns by screen width), with larger round avatars, rounded cards, a sticky header and a full-width layout. Adds a filter pill bar (All, Pics, Vids, Text, Writings, AMA, Audio, For You), a floating back-to-top button, and scroll-position memory. Cards are moved not cloned, so Love, Superlove and Comment still work and infinite scroll stays intact.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         FetLife Suite - Home Feed
// @namespace    http://tampermonkey.net/
// @version      23
// @description  Rebuilds the FetLife home feed as a responsive masonry grid (1 to 4 columns by screen width), with larger round avatars, rounded cards, a sticky header and a full-width layout. Adds a filter pill bar (All, Pics, Vids, Text, Writings, AMA, Audio, For You), a floating back-to-top button, and scroll-position memory. Cards are moved not cloned, so Love, Superlove and Comment still work and infinite scroll stays intact.

// @author       RYSTA
// @match        https://fetlife.com/*
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const IS_HOME_PATH = /^\/home(\/|$)/.test(window.location.pathname);

    // ========================================================================
    // GLOBAL: floating back-to-top button + feed scroll-position memory
    // (mounts on every page so the button is always available)
    // ========================================================================
    GM_addStyle(`
        #fl-back-to-top {
            position: fixed;
            bottom: 28px;
            right: 28px;
            z-index: 9999;
            width: 44px;
            height: 44px;
            border-radius: 50%;
            background: rgba(30, 30, 30, 0.85);
            border: 1px solid #444;
            color: #ccc;
            font-size: 20px;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            opacity: 0;
            visibility: hidden;
            transition: opacity 0.25s ease, visibility 0.25s ease, background 0.2s ease;
            backdrop-filter: blur(6px);
            -webkit-backdrop-filter: blur(6px);
            box-shadow: 0 2px 8px rgba(0,0,0,0.4);
        }
        #fl-back-to-top.fl-visible { opacity: 1; visibility: visible; }
        #fl-back-to-top:hover { background: #e11d48; border-color: #e11d48; color: #fff; }
        #fl-back-to-top svg { width: 20px; height: 20px; fill: currentColor; }
    `);

    const btn = document.createElement('button');
    btn.id = 'fl-back-to-top';
    btn.title = 'Back to top';
    btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M12 4l-8 8h5v8h6v-8h5z"/></svg>';
    document.body.appendChild(btn);
    btn.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));

    let topTicking = false;
    window.addEventListener('scroll', () => {
        if (!topTicking) {
            requestAnimationFrame(() => {
                btn.classList.toggle('fl-visible', window.scrollY > 600);
                topTicking = false;
            });
            topTicking = true;
        }
    });

    // Feed scroll memory — restore Y position when returning to /home via Back button
    const FEED_KEY = 'fl-feed-scroll-pos';
    const isFeedPath = window.location.pathname === '/home' || window.location.pathname.startsWith('/home/');
    if (isFeedPath) {
        const saved = sessionStorage.getItem(FEED_KEY);
        if (saved && performance.navigation.type === 2) {
            requestAnimationFrame(() => window.scrollTo(0, parseInt(saved)));
        }
        let saveTimer = null;
        window.addEventListener('scroll', () => {
            clearTimeout(saveTimer);
            saveTimer = setTimeout(() => {
                sessionStorage.setItem(FEED_KEY, window.scrollY.toString());
            }, 300);
        });
    }

    // ========================================================================
    // The remainder applies only to the home feed and its filter pages
    // ========================================================================
    if (!IS_HOME_PATH) {
        // Still mount a no-op SPA observer so the back-to-top stays alive across
        // soft navigations between /home and other pages.
        return;
    }

    // ========================================================================
    // FILTER PILL BAR
    // ========================================================================
    const FILTERS = [
        { label: 'All',      path: '/home' },
        { label: 'Pics',     path: '/home/pictures' },
        { label: 'Vids',     path: '/home/videos' },
        { label: 'Text',     path: '/home/statuses' },
        { label: 'Writings', path: '/home/writings' },
        { label: 'AMA',      path: '/home/ama' },
        { label: 'Audio',    path: '/home/audio' },
        { label: 'For You',  path: '/home/for-you' }
    ];

    GM_addStyle(`
        #fl-feed-filters {
            display: flex;
            gap: 6px;
            padding: 8px 0 10px 0;
            flex-wrap: wrap;
            align-items: center;
            width: 100%;
            box-sizing: border-box;
        }
        #fl-feed-filters a {
            display: inline-block;
            padding: 5px 14px;
            border-radius: 20px;
            font-size: 12px;
            font-weight: 600;
            text-decoration: none;
            color: #aaa;
            background: #1e1e1e;
            border: 1px solid #333;
            transition: all 0.15s ease;
            white-space: nowrap;
            letter-spacing: 0.02em;
            flex-shrink: 0;
        }
        #fl-feed-filters a:hover { color: #fff; background: #2a2a2a; border-color: #555; }
        #fl-feed-filters a.fl-filter-active { color: #fff; background: #e11d48; border-color: #e11d48; }

        @media (max-width: 720px) {
            #fl-feed-filters {
                flex-wrap: nowrap;
                overflow-x: auto;
                -webkit-overflow-scrolling: touch;
                scrollbar-width: none;
                padding: 8px 14px 10px 14px;
                gap: 8px;
            }
            #fl-feed-filters::-webkit-scrollbar { display: none; }
            #fl-feed-filters a { padding: 8px 16px; font-size: 13px; }
        }
    `);

    function getCurrentFilter() {
        const path = window.location.pathname;
        if (path === '/home') return '/home';
        for (const f of FILTERS) if (f.path !== '/home' && path.startsWith(f.path)) return f.path;
        return '/home';
    }

    function injectFilterBar() {
        if (document.getElementById('fl-feed-filters')) return;
        const bar = document.createElement('div');
        bar.id = 'fl-feed-filters';
        const current = getCurrentFilter();
        FILTERS.forEach(f => {
            const a = document.createElement('a');
            a.href = f.path;
            a.textContent = f.label;
            if (f.path === current) a.classList.add('fl-filter-active');
            bar.appendChild(a);
        });

        const headings = document.querySelectorAll('h1');
        for (const h of headings) {
            if (h.getBoundingClientRect().width > 0 &&
                (h.textContent.includes('Activity') || h.textContent.includes('For You') ||
                 h.textContent.includes('Pictures') || h.textContent.includes('Videos') ||
                 h.textContent.includes('Statuses') || h.textContent.includes('Writings') ||
                 h.textContent.includes('AMA') || h.textContent.includes('Audio'))) {
                const container = h.closest('div');
                if (container) {
                    container.parentNode.insertBefore(bar, container.nextSibling);
                    scrollActiveIntoView(bar);
                    return;
                }
            }
        }

        const main = document.getElementById('responsive-feed') || document.querySelector('main');
        if (main) {
            const children = Array.from(main.children);
            for (let i = 0; i < children.length; i++) {
                const text = children[i].textContent || '';
                if (text.includes('Upload') || text.includes('kinky mind') || text.includes('Write')) {
                    main.insertBefore(bar, children[i + 1] || null);
                    scrollActiveIntoView(bar);
                    return;
                }
            }
            main.insertBefore(bar, main.firstChild);
            scrollActiveIntoView(bar);
        }
    }

    function scrollActiveIntoView(bar) {
        requestAnimationFrame(() => {
            const active = bar.querySelector('.fl-filter-active');
            if (active && bar.scrollWidth > bar.clientWidth) {
                active.scrollIntoView({ inline: 'center', block: 'nearest' });
            }
        });
    }

    injectFilterBar();
    const filterObserver = new MutationObserver(() => {
        if (!document.getElementById('fl-feed-filters')) injectFilterBar();
    });
    filterObserver.observe(document.body, { childList: true, subtree: false });

    // ========================================================================
    // THEME + MASONRY STYLES
    // ========================================================================
    function getThemeVars() {
        const rgb = window.getComputedStyle(document.body).backgroundColor.match(/\d+/g);
        const isDark = !rgb || (parseInt(rgb[0]) * 0.299 + parseInt(rgb[1]) * 0.587 + parseInt(rgb[2]) * 0.114) < 128;
        return isDark
            ? '--card-bg:#121212; --card-border:none; --card-shadow:none; --avatar-border:2px solid #333;'
            : '--card-bg:#f0f0f0; --card-border:1px solid #e2e8f0; --card-shadow:0 4px 6px -1px rgba(0,0,0,0); --avatar-border:2px solid #e2e8f0;';
    }

    GM_addStyle(`
        body.fl-home-active { ${getThemeVars()} }
        body.fl-home-active .max-w-screen-xl {
            max-width: 100vw !important;
            padding-left: 1.5rem !important;
            padding-right: 1.5rem !important;
        }
        @media (max-width: 720px) {
            body.fl-home-active .max-w-screen-xl {
                padding-left: 0.5rem !important;
                padding-right: 0.5rem !important;
            }
            #fl-masonry-wrap { gap: 0.75rem !important; }
            .fl-masonry-col > div { padding: 0 0.75rem !important; }
            body.fl-home-active .flex.flex-col.lg\\:flex-row > aside { display: none !important; }
        }
        body.fl-home-active .mx-auto.max-w-3xl { max-width: 100% !important; margin: 0 !important; }
        body.fl-home-active .flex.flex-col.lg\\:flex-row > aside { display: none !important; }
        body.fl-home-active .flex.flex-col.lg\\:flex-row > main {
            padding-left: 0 !important; padding-right: 0 !important;
        }

        #fl-masonry-wrap {
            display: flex !important;
            gap: 1.5rem;
            width: 100%;
            align-items: flex-start;
        }
        .fl-masonry-col {
            flex: 1;
            display: flex;
            flex-direction: column;
            gap: 1.5rem;
            min-width: 0;
        }

        .fl-masonry-col > div {
            border-radius: 12px;
            padding: 0 1.5rem !important;
            background-color: var(--card-bg) !important;
            border: var(--card-border) !important;
            box-shadow: var(--card-shadow) !important;
        }
        .fl-masonry-col > div header a.flex-none {
            width: 72px !important; height: 72px !important; margin-right: 1.25rem !important;
        }
        .fl-masonry-col > div header a.flex-none img {
            width: 100% !important; height: 100% !important;
            border-radius: 50% !important; object-fit: cover;
            border: var(--avatar-border) !important;
        }
        .fl-masonry-col .pr-8 { padding-right: 0 !important; }
        .fl-masonry-col > div header { align-items: center !important; margin-bottom: 0.5rem; }
        .fl-masonry-col > div header .leading-normal { font-size: 1.15em; font-weight: 700; }
        .fl-masonry-col > div article .overflow-hidden,
        .fl-masonry-col > div article img { border-radius: 8px !important; }
        body.fl-home-active header.flex.h-14 { position: sticky !important; top: 0; z-index: 50; }
    `);

    // ========================================================================
    // SAFE MOVE-BASED MASONRY
    // Move cards (don't clone) so Vue Love/Superlove/Comment bindings stay alive.
    // ========================================================================
    let masonryWrap = null;
    let columns = [];
    let placedItems = new WeakSet();
    let currentColCount = 0;
    let postObserver = null;

    function getColumnCount() {
        const w = window.innerWidth;
        if (w >= 2400) return 4;
        if (w >= 1200) return 3;
        if (w >= 720) return 2;
        return 1;
    }

    function getShortestColumn() {
        let shortest = 0, minHeight = Infinity;
        columns.forEach((col, i) => {
            const h = col.offsetHeight;
            if (h < minHeight) { minHeight = h; shortest = i; }
        });
        return columns[shortest];
    }

    function createMasonryContainer(storiesList) {
        if (masonryWrap) { masonryWrap.remove(); masonryWrap = null; columns = []; }
        const colCount = getColumnCount();
        currentColCount = colCount;
        masonryWrap = document.createElement('div');
        masonryWrap.id = 'fl-masonry-wrap';
        for (let i = 0; i < colCount; i++) {
            const col = document.createElement('div');
            col.className = 'fl-masonry-col';
            masonryWrap.appendChild(col);
            columns.push(col);
        }
        storiesList.parentNode.insertBefore(masonryWrap, storiesList);
    }

    function distributeNewItems(storiesList) {
        const items = [];
        for (const child of Array.from(storiesList.children)) {
            if (child.nodeType !== 1) continue;
            if (child.classList.contains('infinite-loading-container')) continue;
            if (placedItems.has(child)) continue;
            items.push(child);
        }
        if (items.length === 0) return;
        items.forEach(item => {
            placedItems.add(item);
            getShortestColumn().appendChild(item);
        });
    }

    let resizeTimer = null;
    window.addEventListener('resize', () => {
        clearTimeout(resizeTimer);
        resizeTimer = setTimeout(() => {
            const newColCount = getColumnCount();
            if (newColCount !== currentColCount) {
                const storiesList = document.getElementById('stories-list');
                if (!storiesList) return;
                columns.forEach(col => {
                    while (col.firstChild) {
                        placedItems.delete(col.firstChild);
                        storiesList.insertBefore(col.firstChild, storiesList.querySelector('.infinite-loading-container'));
                    }
                });
                createMasonryContainer(storiesList);
                distributeNewItems(storiesList);
            }
        }, 250);
    });

    function initMasonry() {
        const storiesList = document.getElementById('stories-list');
        if (!storiesList) return;
        if (!masonryWrap) createMasonryContainer(storiesList);
        distributeNewItems(storiesList);
        if (postObserver) postObserver.disconnect();
        postObserver = new MutationObserver((mutations) => {
            let hasNewItems = false;
            for (const m of mutations) {
                for (const node of m.addedNodes) {
                    if (node.nodeType === 1 &&
                        !node.classList.contains('infinite-loading-container') &&
                        !placedItems.has(node)) { hasNewItems = true; break; }
                }
                if (hasNewItems) break;
            }
            if (hasNewItems) setTimeout(() => distributeNewItems(storiesList), 150);
        });
        postObserver.observe(storiesList, { childList: true });
        fixInfiniteLoader();
    }

    // Masonry empties stories-list which collapses .infinite-loading-container to 0px,
    // breaking IntersectionObserver-driven infinite scroll. Move the loader after the
    // masonry container so it stays in the scroll flow.
    function fixInfiniteLoader() {
        let attempts = 0;
        const interval = setInterval(() => {
            attempts++;
            const masonry = document.getElementById('fl-masonry-wrap');
            const storiesList = document.getElementById('stories-list');
            const loader = document.querySelector('.infinite-loading-container');
            if (masonry && storiesList && loader && storiesList.contains(loader)) {
                masonry.parentElement.appendChild(loader);
                clearInterval(interval);
                return;
            }
            if (attempts > 30) clearInterval(interval);
        }, 500);
    }

    function checkPage() {
        const isHome = window.location.pathname.startsWith('/home');
        document.body.classList.toggle('fl-home-active', isHome);
        if (isHome) {
            setTimeout(initMasonry, 500);
        } else {
            if (postObserver) postObserver.disconnect();
            if (masonryWrap) {
                const storiesList = document.getElementById('stories-list');
                if (storiesList) {
                    columns.forEach(col => {
                        while (col.firstChild) storiesList.appendChild(col.firstChild);
                    });
                }
                masonryWrap.remove();
                masonryWrap = null;
                columns = [];
                placedItems = new WeakSet();
                currentColCount = 0;
            }
        }
    }

    checkPage();

    const bodyObserver = new MutationObserver(() => checkPage());
    bodyObserver.observe(document.body, { childList: true, subtree: false });

    const origPushState = history.pushState;
    history.pushState = function () {
        origPushState.apply(this, arguments);
        setTimeout(checkPage, 100);
    };
    window.addEventListener('popstate', () => setTimeout(checkPage, 100));
})();