// ==UserScript==
// @name Reddit Snap Scroll
// @description Keyboard navigation (W/S), highlight, open (E), hide previous post
// @name:ru Reddit Snap Scroll — навигация и скрытие
// @description:ru Навигация по постам (W/S), подсветка, открытие (E), скрытие предыдущего поста
// @namespace https://git.prizmed.com/Leviann/tampermonkey-personal
// @version 2025.09.04.3
// @author Farid Ismailov
// @match https://www.reddit.com/*
// @grant GM_openInTab
// @run-at document-end
// @icon https://www.reddit.com/favicon.ico
// @license Personal
// ==/UserScript==
(function () {
'use strict';
const POST_SELECTOR = 'article';
const HEADER_SELECTOR = 'header';
const CENTER_OFFSET = 30;
const HIGHLIGHT_STYLE = 'outline:2px solid orange;outline-offset:2px;';
const AUTO_HIDE_ENABLED = false;
const HIDE_PREVIOUS_ON_NEXT = true;
const AUTO_HIDE_DEBOUNCE_MS = 120;
const MENU_APPEAR_TIMEOUT_MS = 1500;
const MENU_POLL_INTERVAL_MS = 120;
const SEEN_ACTIVATE_RATIO = 0.55;
const header = document.querySelector(HEADER_SELECTOR);
const headerHeight = header ? header.offsetHeight : 0;
let currentHighlightedPost = null;
const seenPosts = new WeakSet();
const hiddenPosts = new WeakSet();
let autoHideScheduled = false;
let autoHideRunning = false;
function getCookie(name) {
const pattern = new RegExp('(?:^|; )' + name.replace(/([.$?*|{}()\[\]\\\/\+^])/g, '\\$1') + '=([^;]*)');
const match = document.cookie.match(pattern);
return match ? decodeURIComponent(match[1]) : null;
}
function getCsrfToken() {
return getCookie('csrf_token') || getCookie('csrfToken') || getCookie('csrf') || '';
}
function isTypingTarget(el) {
if (!el) return false;
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable) return true;
return false;
}
function getCenteredScrollPosition(post) {
const rect = post.getBoundingClientRect();
const top = rect.top + window.scrollY;
const h = rect.height;
const wh = window.innerHeight;
return top - wh / 2 + h / 2 - headerHeight + CENTER_OFFSET;
}
function highlightPost(post) {
if (currentHighlightedPost) {
currentHighlightedPost.style.cssText = '';
}
if (post) {
post.style.cssText = HIGHLIGHT_STYLE;
currentHighlightedPost = post;
}
}
function listPosts() {
return Array.from(document.querySelectorAll(POST_SELECTOR));
}
function resolveThingId(post) {
try {
const idAttr = post.getAttribute('id');
if (idAttr && /^t3_/.test(idAttr)) return idAttr;
} catch (_) { }
try {
const sp = post.closest('shreddit-post');
if (sp) {
const pid = sp.getAttribute('post-id');
if (pid) return pid;
}
} catch (_) { }
try {
const a = post.querySelector('a[href*="/comments/"]');
if (a) {
const href = a.getAttribute('href') || '';
const m = href.match(/\/comments\/([a-z0-9]+)\//i);
if (m && m[1]) return 't3_' + m[1];
}
} catch (_) { }
return '';
}
async function apiHideByThingId(thingId, csrfToken) {
if (!thingId || !csrfToken) return false;
try {
const res = await fetch('/svc/shreddit/graphql', {
method: 'POST',
credentials: 'same-origin',
headers: {
'accept': 'application/json',
'content-type': 'application/json'
},
body: JSON.stringify({
operation: 'UpdatePostHideState',
variables: { input: { postId: thingId, hideState: 'HIDDEN' } },
csrf_token: csrfToken
})
});
if (!res.ok) return false;
const data = await res.json().catch(() => ({}));
const ok = !!(data && data.data && data.data.updatePostHideState && data.data.updatePostHideState.ok);
return ok;
} catch (_) {
return false;
}
}
function findNextPost() {
for (const post of document.querySelectorAll(POST_SELECTOR)) {
if (post.getBoundingClientRect().top > window.innerHeight / 2) {
return post;
}
}
return null;
}
function findPreviousPost() {
const posts = document.querySelectorAll(POST_SELECTOR);
for (let i = posts.length - 1; i >= 0; i--) {
if (posts[i].getBoundingClientRect().bottom < window.innerHeight / 2) {
return posts[i];
}
}
return null;
}
function isFeedPage() {
const p = location.pathname;
if (p.includes('/comments/')) return false;
return true;
}
function rectCenterY(rect) {
return rect.top + rect.height / 2;
}
function shouldMarkSeen(rect) {
const centerY = rectCenterY(rect);
return centerY >= 0 && centerY <= window.innerHeight * SEEN_ACTIVATE_RATIO;
}
function shouldHide(rect) {
return rect.bottom < (headerHeight + 4);
}
function getOverflowMenu(post) {
let el = null;
try { el = post.querySelector('shreddit-post-overflow-menu'); } catch (_) { }
if (el) return el;
const postId = (function () {
const idAttr = post.getAttribute('id');
if (idAttr && /^t3_/.test(idAttr)) return idAttr;
const sp = post.closest('shreddit-post');
if (sp && sp.getAttribute) {
const pid = sp.getAttribute('post-id');
if (pid) return pid;
}
return null;
})();
if (postId) {
const byId = document.querySelector(`shreddit-post-overflow-menu[post-id="${postId}"]`);
if (byId) return byId;
}
const all = Array.from(document.querySelectorAll('shreddit-post-overflow-menu'));
if (!all.length) return null;
const pr = post.getBoundingClientRect();
all.sort((a, b) => distanceBetweenRects(a.getBoundingClientRect(), pr) - distanceBetweenRects(b.getBoundingClientRect(), pr));
return all[0] || null;
}
function elementText(el) {
if (!el) return '';
const txt = (el.getAttribute('aria-label') || '') + ' ' + (el.textContent || '');
return txt.trim().toLowerCase();
}
function isHideMenuItem(btn) {
const t = elementText(btn);
if (!t) return false;
if (t.includes('unhide')) return false;
return t.includes(' hide') || t.startsWith('hide') || t.includes('скрыть') || t.includes('hide post') || t.includes('скрыть публикацию');
}
function distanceBetweenRects(a, b) {
const ax = a.left + a.width / 2;
const ay = a.top + a.height / 2;
const bx = b.left + b.width / 2;
const by = b.top + b.height / 2;
const dx = ax - bx;
const dy = ay - by;
return Math.hypot(dx, dy);
}
function queryVisibleMenuItems() {
const all = Array.from(document.querySelectorAll('button[role="menuitem"], [role="menuitem"]'));
return all.filter(b => b instanceof HTMLElement && b.offsetParent !== null);
}
function findNearestHideMenuItem(anchorRect) {
const items = queryVisibleMenuItems().filter(isHideMenuItem);
if (!items.length) return null;
items.sort((a, b) => distanceBetweenRects(a.getBoundingClientRect(), anchorRect) - distanceBetweenRects(b.getBoundingClientRect(), anchorRect));
return items[0] || null;
}
function smartClick(el) {
try { el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })); } catch (_) { }
try { el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, composed: true })); } catch (_) { }
try { el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, composed: true })); } catch (_) { }
try { el.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); } catch (_) { }
}
function findNearestMoreButton(post) {
const pr = post.getBoundingClientRect();
const cand = Array.from(document.querySelectorAll('button[aria-haspopup="menu"], button[aria-label*="more" i], button[aria-label*="options" i]'));
if (!cand.length) return null;
cand.sort((a, b) => distanceBetweenRects(a.getBoundingClientRect(), pr) - distanceBetweenRects(b.getBoundingClientRect(), pr));
return cand[0] || null;
}
function openOverflow(overflowEl, post) {
try {
const root = overflowEl.shadowRoot || overflowEl;
let btn = root.querySelector('button[aria-label*="More" i], button[aria-label*="more" i], button, [role="button"]');
if (!btn) btn = overflowEl.querySelector('button, [role="button"]');
if (btn) { smartClick(btn); return true; }
} catch (_) { }
try { smartClick(overflowEl); return true; } catch (_) { }
const altBtn = findNearestMoreButton(post);
if (altBtn) { smartClick(altBtn); return true; }
return false;
}
function waitForMenuAndHide(anchorRect, deadlineMs) {
const end = Date.now() + deadlineMs;
return new Promise(resolve => {
const tick = () => {
const item = findNearestHideMenuItem(anchorRect);
if (item) {
item.click();
resolve(true);
return;
}
if (Date.now() >= end) { resolve(false); return; }
setTimeout(tick, MENU_POLL_INTERVAL_MS);
};
tick();
});
}
async function tryHidePost(post) {
if (hiddenPosts.has(post)) return false;
const thingId = resolveThingId(post);
const csrf = getCsrfToken();
let ok = false;
if (thingId && csrf) {
ok = await apiHideByThingId(thingId, csrf);
}
if (!ok) {
const overflow = getOverflowMenu(post);
if (!overflow) return false;
const anchorRect = overflow.getBoundingClientRect();
const opened = openOverflow(overflow, post);
if (!opened) return false;
ok = await waitForMenuAndHide(anchorRect, MENU_APPEAR_TIMEOUT_MS);
}
if (ok) {
hiddenPosts.add(post);
return true;
}
return false;
}
async function hidePreviousIfNeeded(prevPost) {
if (!HIDE_PREVIOUS_ON_NEXT) return;
if (!prevPost) return;
try { await tryHidePost(prevPost); } catch (_) { }
}
async function autoHideTick() {
if (!AUTO_HIDE_ENABLED || !isFeedPage()) return;
if (autoHideRunning) return;
autoHideRunning = true;
try {
const posts = listPosts();
for (const post of posts) {
if (hiddenPosts.has(post)) continue;
const rect = post.getBoundingClientRect();
if (!seenPosts.has(post) && shouldMarkSeen(rect)) {
seenPosts.add(post);
}
if (seenPosts.has(post) && shouldHide(rect)) {
// Try hide and stop early if we actually hid one, to avoid multi-clicks at once
const ok = await tryHidePost(post);
if (ok) break;
}
}
} finally {
autoHideRunning = false;
autoHideScheduled = false;
}
}
function scheduleAutoHide() {
if (!AUTO_HIDE_ENABLED || !isFeedPage()) return;
if (autoHideScheduled) return;
autoHideScheduled = true;
setTimeout(() => { autoHideTick(); }, AUTO_HIDE_DEBOUNCE_MS);
}
function getGallery(container) {
const faceplate = container.querySelector('faceplate-carousel');
if (faceplate) return { type: 'faceplate', el: faceplate };
const gallery = container.querySelector('gallery-carousel');
if (gallery) return { type: 'gallery', el: gallery };
return { type: 'none', el: null };
}
function getCarouselButtons(faceplate) {
const prev = faceplate.querySelector('span[slot="prevButton"] button, [slot="prevButton"] button, button[aria-label="Previous page"], button[aria-label^="Previous"]');
const next = faceplate.querySelector('span[slot="nextButton"] button, [slot="nextButton"] button, button[aria-label="Next page"], button[aria-label^="Next"]');
return { prev, next };
}
function dispatchArrow(targetEl, key) {
try {
const ev1 = new KeyboardEvent('keydown', { key, code: key, bubbles: true, composed: true });
targetEl.dispatchEvent(ev1);
} catch (_) { }
try {
const ev2 = new KeyboardEvent('keydown', { key, code: key, bubbles: true, composed: true });
document.dispatchEvent(ev2);
} catch (_) { }
try {
const ev3 = new KeyboardEvent('keydown', { key, code: key, bubbles: true, composed: true });
window.dispatchEvent(ev3);
} catch (_) { }
}
function navigateCarousel(direction) {
const target = (function () {
if (currentHighlightedPost) return currentHighlightedPost;
const next = findNextPost();
if (next) return next;
return findPreviousPost();
})();
if (!target) return;
const gallery = getGallery(target);
if (gallery.type === 'faceplate' || gallery.type === 'gallery') {
// Avoid hiding carousel slides as "posts"
return;
}
if (gallery.type === 'faceplate') {
const { prev, next } = getCarouselButtons(gallery.el);
if (direction === 'next' && next) next.click();
else if (direction === 'prev' && prev) prev.click();
return;
}
if (gallery.type === 'gallery') {
const el = gallery.el;
if (direction === 'next' && typeof el.next === 'function') { el.next(); return; }
if (direction === 'prev' && typeof el.prev === 'function') { el.prev(); return; }
if (direction === 'next' && typeof el.nextPage === 'function') { el.nextPage(); return; }
if (direction === 'prev' && typeof el.prevPage === 'function') { el.prevPage(); return; }
const key = direction === 'next' ? 'ArrowRight' : 'ArrowLeft';
dispatchArrow(el, key);
return;
}
}
function handleKeydown(event) {
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (isTypingTarget(event.target)) return;
if (event.code === 'KeyS') {
const prevToHide = currentHighlightedPost;
const next = findNextPost();
if (next) {
window.scrollTo({ top: getCenteredScrollPosition(next), behavior: 'auto' });
highlightPost(next);
hidePreviousIfNeeded(prevToHide);
scheduleAutoHide();
}
} else if (event.code === 'KeyW') {
const prev = findPreviousPost();
if (prev) {
window.scrollTo({ top: getCenteredScrollPosition(prev), behavior: 'auto' });
highlightPost(prev);
scheduleAutoHide();
}
} else if (event.code === 'KeyE' || event.key === 'у' || event.key === 'У') {
const target = (function () {
if (currentHighlightedPost) return currentHighlightedPost;
const next = findNextPost();
if (next) return next;
return findPreviousPost();
})();
if (target) {
let link = target.querySelector('a[data-click-id="body"]');
if (!link) link = target.querySelector('a[href*="/comments/"]');
if (!link) link = target.querySelector('a[href]:not([href^="#"]):not([href^="javascript:"])');
if (link) {
let url = link.getAttribute('href');
if (url && url.startsWith('/')) url = location.origin + url;
if (url) {
if (typeof GM_openInTab === 'function') {
GM_openInTab(url, { active: false, insert: true, setParent: true });
} else {
window.open(url, '_blank', 'noopener,noreferrer');
}
}
}
}
} else if (event.code === 'KeyD' || event.key === 'в' || event.key === 'В') {
navigateCarousel('next');
} else if (event.code === 'KeyA' || event.key === 'ф' || event.key === 'Ф') {
navigateCarousel('prev');
}
}
// Вешаем на window, фаза захвата
window.addEventListener('keydown', handleKeydown, true);
window.addEventListener('scroll', scheduleAutoHide, { passive: true });
// Отслеживаем динамически подгружаемые посты (слушатель уже на window, дополнительная переинициализация не нужна)
const observer = new MutationObserver(() => { scheduleAutoHide(); });
observer.observe(document.body, { childList: true, subtree: true });
// First run
scheduleAutoHide();
})();