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.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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