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.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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