A Tampermonkey script that saves your timeline position and returns to it on demand
// ==UserScript==
// @name Twitter/X Timeline Position Saver
// @name:de Twitter/X Timeline Position Saver
// @namespace http://tampermonkey.net/
// @version 2.2
// @description A Tampermonkey script that saves your timeline position and returns to it on demand
// @description:de Ein Tampermonkey-Skript, das Ihre Timeline-Position speichert und bei Bedarf dorthin zurückkehrt
// @author zaengerlein
// @license MIT
// @match https://twitter.com/*
// @match https://x.com/*
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
// ============ KONFIGURATION ============
const CONFIG = {
// Zeitspanne in Minuten, innerhalb derer die Position wiederhergestellt wird
maxAgeMinutes: 60,
// Wie oft die aktuelle Position gespeichert wird (in ms)
saveIntervalMs: 2000,
// Pause zwischen Scroll-Schritten beim Suchen (in ms)
// Muss lang genug sein damit Twitter neue Tweets laden kann
scrollStepDelayMs: 300,
// Scroll-Schrittweite in Pixeln (nicht mehr verwendet, scrollt jetzt zum Seitenende)
scrollStepPx: 800,
// Maximale Scroll-Versuche bevor aufgegeben wird
maxScrollAttempts: 150,
// Benachrichtigung anzeigen?
showNotifications: true,
// Debug-Modus (mehr Console-Ausgaben)
debug: false
};
// ============ STORAGE KEYS ============
// Automatische Position
const STORAGE_KEY_TWEET_ID = 'twitter_last_tweet_id';
const STORAGE_KEY_TIMESTAMP = 'twitter_last_timestamp';
const STORAGE_KEY_PATH = 'twitter_last_path';
// Manuelle Position (Lesezeichen)
const STORAGE_KEY_MANUAL_TWEET_ID = 'twitter_manual_tweet_id';
const STORAGE_KEY_MANUAL_TIMESTAMP = 'twitter_manual_timestamp';
const STORAGE_KEY_MANUAL_PATH = 'twitter_manual_path';
const STORAGE_KEY_MANUAL_TAB = 'twitter_manual_tab';
// ============ SCROLL ABORT CONTROLLER ============
let currentScrollAbort = null;
function abortCurrentScroll() {
if (currentScrollAbort) {
currentScrollAbort.aborted = true;
log('Scroll-Vorgang abgebrochen');
}
}
// ============ HILFSFUNKTIONEN ============
function log(...args) {
if (CONFIG.debug) {
console.log('[Timeline Saver]', ...args);
}
}
function showNotification(message, type = 'info') {
if (!CONFIG.showNotifications) return;
const notification = document.createElement('div');
notification.textContent = message;
notification.style.cssText = `
position: fixed;
top: 70px;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
border-radius: 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
font-weight: 500;
z-index: 10000;
opacity: 0;
transition: opacity 0.3s ease;
${type === 'success'
? 'background: #1d9bf0; color: white;'
: type === 'error'
? 'background: #f4212e; color: white;'
: 'background: #333; color: white;'}
`;
document.body.appendChild(notification);
// Fade in
requestAnimationFrame(() => {
notification.style.opacity = '1';
});
// Fade out und entfernen
setTimeout(() => {
notification.style.opacity = '0';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
function getCurrentPath() {
return window.location.pathname;
}
function isTimelinePage() {
const path = getCurrentPath();
// Home Timeline
if (path === '/home' || path === '/') return true;
// Profil-Hauptseite (z.B. /username)
if (path.match(/^\/[^/]+$/)) return true;
// Profil-Tabs (z.B. /username/with_replies, /username/media, /username/likes)
if (path.match(/^\/[^/]+\/(with_replies|media|likes|highlights)$/)) return true;
// Search
if (path.startsWith('/search')) return true;
// Bookmarks
if (path === '/i/bookmarks') return true;
// Lists
if (path.match(/^\/i\/lists\/\d+$/)) return true;
return false;
}
function extractTweetId(article) {
// Suche nach dem Status-Link im Article
const statusLink = article.querySelector('a[href*="/status/"]');
if (statusLink) {
const match = statusLink.href.match(/\/status\/(\d+)/);
if (match) return match[1];
}
return null;
}
function getVisibleTweets() {
const articles = document.querySelectorAll('article[data-testid="tweet"]');
const visible = [];
articles.forEach(article => {
const rect = article.getBoundingClientRect();
// Tweet ist sichtbar wenn er im oberen Drittel des Viewports ist
if (rect.top >= 0 && rect.top < window.innerHeight * 0.5) {
const tweetId = extractTweetId(article);
if (tweetId) {
visible.push({ article, tweetId, top: rect.top });
}
}
});
return visible;
}
function findTweetById(tweetId) {
const links = document.querySelectorAll(`a[href*="/status/${tweetId}"]`);
for (const link of links) {
const article = link.closest('article[data-testid="tweet"]');
if (article) return article;
}
return null;
}
// ============ NAVIGATION TABS ============
function getNavigationTabs() {
// Suche nach der Tab-Navigation über der Timeline
const navContainer = document.querySelector('nav[role="navigation"] div[role="tablist"]');
if (!navContainer) return [];
const tabs = navContainer.querySelectorAll('a[role="tab"], div[role="tab"]');
return Array.from(tabs);
}
function getCurrentTabName() {
const tabs = getNavigationTabs();
for (const tab of tabs) {
// Aktiver Tab hat aria-selected="true"
if (tab.getAttribute('aria-selected') === 'true') {
// Text des Tabs extrahieren
const textElement = tab.querySelector('span');
if (textElement) {
return textElement.textContent.trim();
}
}
}
return null;
}
function clickTab(tabName) {
if (!tabName) return false;
const tabs = getNavigationTabs();
for (const tab of tabs) {
const textElement = tab.querySelector('span');
if (textElement && textElement.textContent.trim() === tabName) {
tab.click();
log(`Tab "${tabName}" geklickt`);
return true;
}
}
log(`Tab "${tabName}" nicht gefunden`);
return false;
}
// ============ SPEICHERN ============
function saveCurrentPosition() {
if (!isTimelinePage()) return;
const visibleTweets = getVisibleTweets();
if (visibleTweets.length === 0) return;
// Nimm den obersten sichtbaren Tweet
const topTweet = visibleTweets[0];
GM_setValue(STORAGE_KEY_TWEET_ID, topTweet.tweetId);
GM_setValue(STORAGE_KEY_TIMESTAMP, Date.now());
GM_setValue(STORAGE_KEY_PATH, getCurrentPath());
log('Position gespeichert:', topTweet.tweetId);
}
// Manuelle Position speichern (Lesezeichen)
function saveManualPosition() {
const visibleTweets = getVisibleTweets();
if (visibleTweets.length === 0) {
showNotification('✗ Kein Tweet sichtbar', 'error');
return false;
}
const topTweet = visibleTweets[0];
const currentTab = getCurrentTabName();
GM_setValue(STORAGE_KEY_MANUAL_TWEET_ID, topTweet.tweetId);
GM_setValue(STORAGE_KEY_MANUAL_TIMESTAMP, Date.now());
GM_setValue(STORAGE_KEY_MANUAL_PATH, getCurrentPath());
GM_setValue(STORAGE_KEY_MANUAL_TAB, currentTab);
log('Manuelle Position gespeichert:', topTweet.tweetId, 'Tab:', currentTab, 'Pfad:', getCurrentPath());
const tabInfo = currentTab ? ` (Tab: ${currentTab})` : '';
showNotification(`🔖 Lesezeichen gespeichert!${tabInfo}`, 'success');
return true;
}
// ============ WIEDERHERSTELLEN ============
async function restorePosition(useManual = false) {
// Vorherigen Scroll-Vorgang abbrechen
abortCurrentScroll();
// Neuen Abort-Controller erstellen
const abortController = { aborted: false };
currentScrollAbort = abortController;
const savedTweetId = GM_getValue(useManual ? STORAGE_KEY_MANUAL_TWEET_ID : STORAGE_KEY_TWEET_ID);
const savedTimestamp = GM_getValue(useManual ? STORAGE_KEY_MANUAL_TIMESTAMP : STORAGE_KEY_TIMESTAMP);
const savedPath = GM_getValue(useManual ? STORAGE_KEY_MANUAL_PATH : STORAGE_KEY_PATH);
const savedTab = useManual ? GM_getValue(STORAGE_KEY_MANUAL_TAB) : null;
const positionType = useManual ? 'Lesezeichen' : 'Position';
if (!savedTweetId || !savedTimestamp) {
log(`Keine gespeicherte ${positionType} gefunden`);
if (useManual) {
showNotification('✗ Kein Lesezeichen vorhanden', 'error');
}
return;
}
// Prüfe ob die Position noch aktuell genug ist (nur für automatische Position)
const ageMinutes = (Date.now() - savedTimestamp) / 1000 / 60;
if (!useManual && ageMinutes > CONFIG.maxAgeMinutes) {
log(`Position zu alt (${ageMinutes.toFixed(1)} Minuten)`);
return;
}
// Prüfe ob wir auf der gleichen Seite sind
if (savedPath && savedPath !== getCurrentPath()) {
if (useManual) {
// Bei manuellem Lesezeichen: Zur gespeicherten Seite navigieren
log(`Navigiere von "${getCurrentPath()}" zu "${savedPath}"`);
showNotification(`🔄 Navigiere zu ${savedPath}...`, 'info');
window.location.href = `https://${window.location.host}${savedPath}`;
// Nach Navigation wird die Seite neu geladen,
// daher speichern wir einen Flag um danach fortzusetzen
GM_setValue('twitter_pending_restore', 'manual');
return;
} else {
log('Andere Seite als gespeichert');
return;
}
}
const ageText = ageMinutes < 1 ? 'gerade eben' :
ageMinutes < 60 ? `vor ${Math.round(ageMinutes)} Min.` :
`vor ${Math.round(ageMinutes / 60)} Std.`;
// Bei manuellem Lesezeichen: Erst zum richtigen Tab wechseln
if (useManual && savedTab) {
const currentTab = getCurrentTabName();
if (currentTab !== savedTab) {
log(`Wechsle von Tab "${currentTab}" zu "${savedTab}"`);
showNotification(`🔄 Wechsle zu Tab "${savedTab}"...`, 'info');
if (clickTab(savedTab)) {
// Warte bis der Tab-Inhalt geladen ist
await new Promise(r => setTimeout(r, 2000));
if (abortController.aborted) return;
} else {
showNotification(`✗ Tab "${savedTab}" nicht gefunden`, 'error');
return;
}
}
}
// Erst zum Seitenanfang scrollen, dann von dort aus suchen
log('Scrolle zum Seitenanfang...');
window.scrollTo({ top: 0, behavior: 'instant' });
await new Promise(r => setTimeout(r, 1000));
if (abortController.aborted) return;
log(`Versuche ${positionType} wiederherzustellen: Tweet ${savedTweetId} (${ageText})`);
showNotification(`🔍 Suche ${positionType}... (${ageText})`, 'info');
let attempts = 0;
let found = false;
while (attempts < CONFIG.maxScrollAttempts && !found && !abortController.aborted) {
const tweet = findTweetById(savedTweetId);
if (tweet) {
tweet.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Visuelles Highlight (unterschiedliche Farbe für manuell)
const highlightColor = useManual ? '#7856ff' : '#1d9bf0';
tweet.style.transition = 'box-shadow 0.3s ease';
tweet.style.boxShadow = `0 0 0 3px ${highlightColor}`;
setTimeout(() => {
tweet.style.boxShadow = '';
}, 2000);
found = true;
log(`${positionType} gefunden und hingescrollt!`);
showNotification(`✓ ${positionType} gefunden!`, 'success');
} else {
// Scrolle zum Seitenende um neue Tweets zu laden
window.scrollTo(0, document.body.scrollHeight);
await new Promise(r => setTimeout(r, CONFIG.scrollStepDelayMs));
attempts++;
if (attempts % 10 === 0) {
log(`Noch am Suchen... (Versuch ${attempts})`);
}
}
}
if (abortController.aborted) {
log('Scroll-Vorgang wurde abgebrochen');
return;
}
if (!found) {
log(`${positionType} nicht gefunden nach`, attempts, 'Versuchen');
showNotification(`✗ ${positionType} nicht gefunden`, 'error');
}
// Controller zurücksetzen
if (currentScrollAbort === abortController) {
currentScrollAbort = null;
}
}
// ============ UI: BUTTONS ============
function createButtons() {
// Container für beide Buttons
const container = document.createElement('div');
container.style.cssText = `
position: fixed;
bottom: 180px;
right: 24px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
z-index: 9999;
`;
// === Button 1: Automatische Position (wie bisher) ===
const autoButton = document.createElement('button');
autoButton.innerHTML = '📍';
autoButton.title = 'Zur automatisch gespeicherten Position springen';
autoButton.style.cssText = `
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: #1d9bf0;
color: white;
font-size: 18px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
transition: transform 0.2s, background 0.2s;
padding-left: 6px;
display: flex;
align-items: center;
justify-content: center;
`;
autoButton.addEventListener('mouseenter', () => {
autoButton.style.transform = 'scale(1.1)';
});
autoButton.addEventListener('mouseleave', () => {
autoButton.style.transform = 'scale(1)';
});
autoButton.addEventListener('click', () => {
restorePosition(false); // Automatische Position
});
// === Button 2: Manuelles Lesezeichen (Split-Button) ===
const manualButtonContainer = document.createElement('div');
manualButtonContainer.style.cssText = `
display: flex;
border-radius: 22px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
`;
// Linke Hälfte: Speichern
const saveHalf = document.createElement('button');
saveHalf.innerHTML = '💾';
saveHalf.title = 'Lesezeichen hier setzen';
saveHalf.style.cssText = `
width: 22px;
height: 44px;
border: none;
background: #7856ff;
color: white;
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
border-right: 1px solid rgba(255,255,255,0.2);
padding-left: 3px;
display: flex;
align-items: center;
justify-content: center;
`;
saveHalf.addEventListener('mouseenter', () => {
saveHalf.style.background = '#6644ee';
});
saveHalf.addEventListener('mouseleave', () => {
saveHalf.style.background = '#7856ff';
});
saveHalf.addEventListener('click', () => {
if (saveManualPosition()) {
// Kurzes visuelles Feedback
saveHalf.innerHTML = '✓';
setTimeout(() => { saveHalf.innerHTML = '💾'; }, 1000);
}
});
// Rechte Hälfte: Laden
const loadHalf = document.createElement('button');
loadHalf.innerHTML = '🔖';
loadHalf.title = 'Zum Lesezeichen springen';
loadHalf.style.cssText = `
width: 22px;
height: 44px;
border: none;
background: #7856ff;
color: white;
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
padding-left: 2px;
display: flex;
align-items: center;
justify-content: center;
`;
loadHalf.addEventListener('mouseenter', () => {
loadHalf.style.background = '#6644ee';
});
loadHalf.addEventListener('mouseleave', () => {
loadHalf.style.background = '#7856ff';
});
loadHalf.addEventListener('click', () => {
restorePosition(true); // Manuelle Position
});
// Zusammenbauen
manualButtonContainer.appendChild(saveHalf);
manualButtonContainer.appendChild(loadHalf);
container.appendChild(manualButtonContainer);
container.appendChild(autoButton);
document.body.appendChild(container);
}
// ============ INITIALISIERUNG ============
function init() {
log('Timeline Position Saver initialisiert');
// Buttons erstellen
createButtons();
// Automatisch Position speichern
setInterval(saveCurrentPosition, CONFIG.saveIntervalMs);
// Position auch beim Verlassen speichern + Scroll abbrechen
window.addEventListener('beforeunload', () => {
abortCurrentScroll();
saveCurrentPosition();
});
// Scroll abbrechen wenn Tab versteckt wird
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
abortCurrentScroll();
}
});
// Scroll abbrechen bei Escape-Taste
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
abortCurrentScroll();
}
});
// Prüfe ob nach Navigation ein Lesezeichen wiederhergestellt werden soll
const pendingRestore = GM_getValue('twitter_pending_restore');
if (pendingRestore) {
GM_setValue('twitter_pending_restore', null);
// Kurz warten bis die Seite geladen ist
setTimeout(() => {
if (pendingRestore === 'manual') {
restorePosition(true);
} else if (pendingRestore === 'auto') {
restorePosition(false);
}
}, 2000);
}
}
// Warten bis die Seite bereit ist
if (document.readyState === 'complete') {
init();
} else {
window.addEventListener('load', init);
}
})();