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