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.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==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));
})();