// ==UserScript== // @name X Timeline Sync // @description Tracks and syncs your last reading position on Twitter/X, with manual and automatic options. Ideal for keeping track of new posts without losing your place. // @description:de Verfolgt und synchronisiert Ihre letzte Leseposition auf Twitter/X, mit manuellen und automatischen Optionen. Perfekt, um neue Beiträge im Blick zu behalten, ohne die aktuelle Position zu verlieren. // @description:es Rastrea y sincroniza tu última posición de lectura en Twitter/X, con opciones manuales y automáticas. Ideal para mantener el seguimiento de las publicaciones nuevas sin perder tu posición. // @description:fr Suit et synchronise votre dernière position de lecture sur Twitter/X, avec des options manuelles et automatiques. Idéal pour suivre les nouveaux posts sans perdre votre place actuelle. // @description:zh-CN 跟踪并同步您在 Twitter/X 上的最后阅读位置,提供手动和自动选项。完美解决在查看新帖子时不丢失当前位置的问题。 // @description:ru Отслеживает и синхронизирует вашу последнюю позицию чтения на Twitter/X с ручными и автоматическими опциями. Идеально подходит для просмотра новых постов без потери текущей позиции. // @description:ja Twitter/X での最後の読み取り位置を追跡して同期します。手動および自動オプションを提供します。新しい投稿を見逃さずに現在の位置を維持するのに最適です。 // @description:pt-BR Rastrea e sincroniza sua última posição de leitura no Twitter/X, com opções manuais e automáticas. Perfeito para acompanhar novos posts sem perder sua posição atual. // @description:hi Twitter/X पर आपकी अंतिम पठन स्थिति को ट्रैक और सिंक करता है, मैनुअल और स्वचालित विकल्पों के साथ। नई पोस्ट देखते समय अपनी वर्तमान स्थिति को खोए बिना इसे ट्रैक करें। // @description:ar يتتبع ويزامن آخر موضع قراءة لك على Twitter/X، مع خيارات يدوية وتلقائية. مثالي لتتبع المشاركات الجديدة دون فقدان موضعك الحالي. // @description:it Traccia e sincronizza la tua ultima posizione di lettura su Twitter/X, con opzioni manuali e automatiche. Ideale per tenere traccia dei nuovi post senza perdere la posizione attuale. // @description:ko Twitter/X에서 마지막 읽기 위치를 추적하고 동기화합니다. 수동 및 자동 옵션 포함. 새로운 게시물을 확인하면서 현재 위치를 잃지 않도록 이상적입니다. // @icon https://x.com/favicon.ico // @namespace http://tampermonkey.net/ // @version 2025-02-26.2 // @author Copiis // @license MIT // @match https://x.com/home // @grant GM_setValue // @grant GM_getValue // @grant GM_download // @downloadURL none // ==/UserScript== (function() { 'use strict'; let lastReadPost = null; let isAutoScrolling = false; let isSearching = false; let isTabFocused = true; let downloadTriggered = false; let isPostLoading = false; let hasScrolledAfterLoad = false; let saveToDownloadFolder = GM_getValue("saveToDownloadFolder", true); const translations = { en: { scriptDisabled: "🚫 Script disabled: Not on the home page.", pageLoaded: "🚀 Page fully loaded. Initializing script...", tabBlur: "🌐 Tab lost focus.", downloadStart: "📥 Starting download of last read position...", alreadyDownloaded: "🗂️ Position already downloaded.", tabFocused: "🟢 Tab refocused.", saveSuccess: "✅ Last read position saved:", saveFail: "⚠️ No valid position to save.", noPostFound: "❌ No top visible post found.", highlightSuccess: "✅ Post highlighted successfully.", searchStart: "🔍 Refined search started...", searchCancel: "⏹️ Search manually canceled.", contentLoadWait: "⌛ Waiting for content to load...", toggleSaveOn: "💾 Save to download folder enabled", toggleSaveOff: "🚫 Save to download folder disabled" }, de: { scriptDisabled: "🚫 Skript deaktiviert: Nicht auf der Home-Seite.", pageLoaded: "🚀 Seite vollständig geladen. Initialisiere Skript...", tabBlur: "🌐 Tab hat den Fokus verloren.", downloadStart: "📥 Starte Download der letzten Leseposition...", alreadyDownloaded: "🗂️ Leseposition bereits im Download-Ordner vorhanden.", tabFocused: "🟢 Tab wieder fokussiert.", saveSuccess: "✅ Leseposition erfolgreich gespeichert:", saveFail: "⚠️ Keine gültige Leseposition zum Speichern.", noPostFound: "❌ Kein oberster sichtbarer Beitrag gefunden.", highlightSuccess: "✅ Beitrag erfolgreich hervorgehoben.", searchStart: "🔍 Verfeinerte Suche gestartet...", searchCancel: "⏹️ Suche manuell abgebrochen.", contentLoadWait: "⌛ Warte darauf, dass der Inhalt geladen wird...", toggleSaveOn: "💾 Speichern im Download-Ordner aktiviert", toggleSaveOff: "🚫 Speichern im Download-Ordner deaktiviert" } }; const userLang = navigator.language.split('-')[0]; const t = (key) => translations[userLang]?.[key] || translations.en[key]; function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } const observer = new IntersectionObserver( entries => { entries.forEach(entry => { if (entry.isIntersecting) { const postData = { timestamp: getPostTimestamp(entry.target), authorHandler: getPostAuthorHandler(entry.target) }; } }); }, { threshold: 0.1 } ); function observeVisiblePosts() { const articles = document.querySelectorAll('article'); const viewportHeight = window.innerHeight; const buffer = viewportHeight * 2; for (let article of articles) { const rect = article.getBoundingClientRect(); if (rect.top < buffer && rect.bottom > -buffer) { observer.observe(article); } else { observer.unobserve(article); } } } function loadNewestLastReadPost() { const data = GM_getValue("lastReadPost", null); if (data) { lastReadPost = JSON.parse(data); console.log(t("saveSuccess"), lastReadPost); } else { console.warn(t("saveFail")); } } function loadLastReadPostFromFile() { loadNewestLastReadPost(); } function saveLastReadPostToFile() { if (lastReadPost && lastReadPost.timestamp && lastReadPost.authorHandler) { GM_setValue("lastReadPost", JSON.stringify(lastReadPost)); console.log(t("saveSuccess"), lastReadPost); } else { console.warn(t("saveFail")); } } function downloadLastReadPost() { if (!saveToDownloadFolder) { console.log("Saving to download folder is disabled."); return; } if (!lastReadPost || !lastReadPost.timestamp || !lastReadPost.authorHandler) { console.warn(t("saveFail")); return; } try { const data = JSON.stringify(lastReadPost, null, 2); const sanitizedHandler = lastReadPost.authorHandler.replace(/[^a-zA-Z0-9-_]/g, ""); const timestamp = new Date(lastReadPost.timestamp).toISOString().replace(/[:.-]/g, "_"); const fileName = `${sanitizedHandler}_${timestamp}.json`; GM_download({ url: `data:application/json;charset=utf-8,${encodeURIComponent(data)}`, name: fileName, onload: () => console.log(`${t("saveSuccess")} ${fileName}`), onerror: (err) => console.error("❌ Error downloading:", err), }); } catch (error) { console.error("❌ Download error:", error); } } function markTopVisiblePost(save = true) { const topPost = getTopVisiblePost(); if (!topPost) { console.log(t("noPostFound")); return; } const postTimestamp = getPostTimestamp(topPost); const authorHandler = getPostAuthorHandler(topPost); if (postTimestamp && authorHandler) { if (save && (!lastReadPost || new Date(postTimestamp) > new Date(lastReadPost.timestamp))) { lastReadPost = { timestamp: postTimestamp, authorHandler }; saveLastReadPostToFile(); } } } function getTopVisiblePost() { return Array.from(document.querySelectorAll("article")).find(post => { const rect = post.getBoundingClientRect(); return rect.top >= 0 && rect.bottom > 0; }); } function getPostTimestamp(post) { const timeElement = post.querySelector("time"); return timeElement ? timeElement.getAttribute("datetime") : null; } function getPostAuthorHandler(post) { const handlerElement = post.querySelector('[role="link"][href*="/"]'); return handlerElement ? handlerElement.getAttribute("href").slice(1) : null; } function startRefinedSearchForLastReadPost() { if (!lastReadPost || !lastReadPost.timestamp || !lastReadPost.authorHandler || isPostLoading) return; console.log(t("searchStart")); const popup = createSearchPopup(); let direction = 1; let scrollAmount = 200; let scrollSpeed = 1000; let scrollInterval = 200; let lastComparison = null; let initialAdjusted = false; let lastScrollDirection = null; let lastScrollY = 0; let jumpMultiplier = 1; function handleSpaceKey(event) { if (event.code === "Space") { console.log(t("searchCancel")); isSearching = false; popup.remove(); window.removeEventListener("keydown", handleSpaceKey); } } window.addEventListener("keydown", handleSpaceKey); function adjustScrollParameters(lastReadTime) { const visiblePosts = getVisiblePosts(); if (visiblePosts.length === 0) return { amount: 200, speed: 1000 }; const nearestPost = visiblePosts.reduce((closest, post) => { const postTime = new Date(post.timestamp); const diffCurrent = Math.abs(postTime - lastReadTime); const diffClosest = Math.abs(new Date(closest.timestamp) - lastReadTime); return diffCurrent < diffClosest ? post : closest; }); const nearestTime = new Date(nearestPost.timestamp); const timeDifference = Math.abs(lastReadTime - nearestTime) / (1000 * 60); let newScrollAmount = 200; let newScrollSpeed = 1000; if (timeDifference < 5) { newScrollAmount = 50; newScrollSpeed = 500; jumpMultiplier = 1; } else if (timeDifference < 30) { newScrollAmount = 100; newScrollSpeed = 1000; jumpMultiplier = 1; } else if (timeDifference < 60) { newScrollAmount = 200; newScrollSpeed = 1500; jumpMultiplier = 1.5; } else if (timeDifference < 1440) { newScrollAmount = 500; newScrollSpeed = 2000; jumpMultiplier = 2; } else { newScrollAmount = 1000; newScrollSpeed = 3000; jumpMultiplier = 3; } newScrollAmount = Math.max(50, Math.min(newScrollAmount * jumpMultiplier, window.innerHeight * 2)); return { amount: newScrollAmount, speed: newScrollSpeed }; } async function search() { if (!isSearching) { popup.remove(); return; } const visiblePosts = getVisiblePosts(); if (visiblePosts.length === 0 && !isPostLoading) { setTimeout(search, scrollInterval); return; } if (!initialAdjusted) { const comparison = compareVisiblePostsToLastReadPost(visiblePosts); adjustInitialScroll(comparison); initialAdjusted = true; } const comparison = compareVisiblePostsToLastReadPost(visiblePosts); const lastReadTime = new Date(lastReadPost.timestamp); let nearestVisiblePostTime = null; if (visiblePosts.length > 0) { nearestVisiblePostTime = new Date(visiblePosts[0].timestamp); } const { amount, speed } = adjustScrollParameters(lastReadTime); scrollAmount = amount; scrollSpeed = speed; scrollInterval = Math.max(30, 1000 / (scrollSpeed / scrollAmount)); const distanceToBottom = document.documentElement.scrollHeight - (window.scrollY + window.innerHeight); if (distanceToBottom < window.innerHeight) { scrollAmount = Math.max(50, scrollAmount / 2); scrollSpeed = Math.max(500, scrollSpeed / 2); scrollInterval = Math.max(30, 1000 / (scrollSpeed / scrollAmount)); } if (comparison === "match") { const matchedPost = findPostByData(lastReadPost); if (matchedPost) { scrollToPostWithHighlight(matchedPost); isSearching = false; popup.remove(); window.removeEventListener("keydown", handleSpaceKey); setTimeout(() => { isAutoScrolling = false; }, 1000); return; } } else if (comparison === "older") { direction = -1; if (lastComparison === "newer") jumpMultiplier *= 0.5; } else if (comparison === "newer") { direction = 1; if (lastComparison === "older") jumpMultiplier *= 0.5; } else if (comparison === "mixed") { scrollAmount = Math.max(50, scrollAmount / 2); scrollSpeed = Math.max(500, scrollSpeed / 2); scrollInterval = Math.max(30, 1000 / (scrollSpeed / scrollAmount)); } if (window.scrollY === 0 && direction === -1) { direction = 1; jumpMultiplier *= 2; } else if (distanceToBottom < 50 && direction === 1) { direction = -1; jumpMultiplier *= 2; } lastComparison = comparison; lastScrollDirection = direction; lastScrollY = window.scrollY; console.log(`Scroll-Richtung: ${direction}, Betrag: ${scrollAmount}px, Geschwindigkeit: ${scrollSpeed}px/s, Intervall: ${scrollInterval}ms, Position: ${window.scrollY}, Zeitdifferenz: ${nearestVisiblePostTime ? Math.abs(lastReadTime - nearestVisiblePostTime) : 'N/A'}`); requestAnimationFrame(() => { window.scrollBy(0, direction * scrollAmount); setTimeout(search, scrollInterval); }); } isSearching = true; search(); } function adjustInitialScroll(comparison) { const initialScrollAmount = 2000; if (comparison === "older") { for (let i = 0; i < 3; i++) { window.scrollBy(0, -initialScrollAmount / 3); } } else if (comparison === "newer") { for (let i = 0; i < 3; i++) { window.scrollBy(0, initialScrollAmount / 3); } } } function createSearchPopup() { const popup = document.createElement("div"); popup.style.cssText = `position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.9); color: #fff; padding: 10px 20px; border-radius: 8px; font-size: 14px; box-shadow: 0 0 10px rgba(255, 255, 255, 0.8); z-index: 10000;`; popup.textContent = "🔍 Refined search in progress... Press SPACE to cancel."; document.body.appendChild(popup); return popup; } function compareVisiblePostsToLastReadPost(posts) { const validPosts = posts.filter(post => post.timestamp && post.authorHandler); if (validPosts.length === 0) return null; const lastReadTime = new Date(lastReadPost.timestamp); if (validPosts.some(post => post.timestamp === lastReadPost.timestamp && post.authorHandler === lastReadPost.authorHandler)) { return "match"; } else if (validPosts.every(post => new Date(post.timestamp) < lastReadTime)) { return "older"; } else if (validPosts.every(post => new Date(post.timestamp) > lastReadTime)) { return "newer"; } else { return "mixed"; } } function scrollToPostWithHighlight(post) { if (!post) return; isAutoScrolling = true; post.style.cssText = `outline: none; box-shadow: 0 0 30px 10px rgba(255, 223, 0, 1); background-color: rgba(255, 223, 0, 0.3); border-radius: 12px; transform: scale(1.1); transition: all 0.3s ease;`; const postRect = post.getBoundingClientRect(); const viewportHeight = window.innerHeight; const scrollY = window.scrollY; const scrollTo = scrollY + postRect.top - viewportHeight / 2 + postRect.height / 2; window.scrollTo({ top: scrollTo, behavior: 'smooth' }); setTimeout(() => { let scrollHandler = function() { post.style.cssText = ""; window.removeEventListener('scroll', scrollHandler); console.log(t("highlightSuccess")); }; window.addEventListener('scroll', scrollHandler); }, 500); } function getVisiblePosts() { return Array.from(document.querySelectorAll("article")).map(post => ({ element: post, timestamp: getPostTimestamp(post), authorHandler: getPostAuthorHandler(post) })).filter(post => post.timestamp && post.authorHandler); } function findPostByData(data) { return Array.from(document.querySelectorAll("article")).find(post => { const postTimestamp = getPostTimestamp(post); const authorHandler = getPostAuthorHandler(post); return postTimestamp === data.timestamp && authorHandler === data.authorHandler; }); } function createButtons() { const container = document.createElement("div"); container.style.cssText = `position: fixed; top: 50%; left: 3px; transform: translateY(-50%); display: flex; flex-direction: column; gap: 3px; z-index: 10000;`; let toggleButton; const buttons = [ { icon: "📂", title: "Load saved reading position", onClick: importLastReadPost }, { icon: "🔍", title: "Start manual search", onClick: startRefinedSearchForLastReadPost }, { icon: saveToDownloadFolder ? "💾" : "🚫", title: "Toggle save to download folder", onClick: function() { saveToDownloadFolder = !saveToDownloadFolder; GM_setValue("saveToDownloadFolder", saveToDownloadFolder); toggleButton.style.background = saveToDownloadFolder ? "rgba(0, 255, 0, 0.9)" : "rgba(255, 0, 0, 0.9)"; toggleButton.textContent = saveToDownloadFolder ? "💾" : "🚫"; console.log(saveToDownloadFolder ? t("toggleSaveOn") : t("toggleSaveOff")); } } ]; buttons.forEach(({ icon, title, onClick }) => { const button = document.createElement("div"); button.style.cssText = `width: 36px; height: 36px; background: ${icon === "💾" || icon === "🚫" ? (saveToDownloadFolder ? "rgba(0, 255, 0, 0.9)" : "rgba(255, 0, 0, 0.9)") : "rgba(0, 0, 0, 0.9)"}; color: #fff; border-radius: 50%; display: flex; justify-content: center; align-items: center; cursor: pointer; font-size: 18px; box-shadow: inset 0 0 10px rgba(255, 255, 255, 0.5); transition: all 0.2s ease;`; button.title = title; button.textContent = icon; button.addEventListener("click", function() { button.style.boxShadow = "inset 0 0 20px rgba(255, 255, 255, 0.8)"; button.style.transform = "scale(0.9)"; setTimeout(() => { button.style.boxShadow = "inset 0 0 10px rgba(255, 255, 255, 0.5)"; button.style.transform = "scale(1)"; onClick(); }, 300); }); ["mouseenter", "mouseleave"].forEach(event => button.addEventListener(event, () => button.style.transform = event === "mouseenter" ? "scale(1.1)" : "scale(1)") ); if (icon === "💾" || icon === "🚫") { toggleButton = button; } container.appendChild(button); }); document.body.appendChild(container); } function importLastReadPost() { const input = document.createElement("input"); input.type = "file"; input.accept = "application/json"; input.style.display = "none"; input.addEventListener("change", (event) => { const file = event.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = () => { try { const importedData = JSON.parse(reader.result); if (importedData.timestamp && importedData.authorHandler) { lastReadPost = importedData; saveLastReadPostToFile(); startRefinedSearchForLastReadPost(); } else { throw new Error("Invalid reading position"); } } catch (error) { console.error("❌ Error importing reading position:", error); } }; reader.readAsText(file); } }); document.body.appendChild(input); input.click(); document.body.removeChild(input); } function observeForNewPosts() { const targetNode = document.querySelector('div[aria-label="Timeline: Your Home Timeline"]') || document.body; const mutationObserver = new MutationObserver(mutations => { for (const mutation of mutations) { if (mutation.type === 'childList') { checkForNewPosts(); } } }); mutationObserver.observe(targetNode, { childList: true, subtree: true }); } function getNewPostsIndicator() { const indicator = document.querySelector('div[aria-label*="undefined"]') || document.querySelector('div[aria-label*="new"]'); console.log(`[Debug] Neuer Beitragsindikator gefunden: ${indicator ? 'Ja' : 'Nein'}`); return indicator; } function clickNewPostsIndicator(indicator, preservedScrollY) { if (indicator && indicator.offsetParent !== null) { console.log("Versuche, auf den neuen Beitrag-Indikator zu klicken."); indicator.click(); console.log("Klick auf den neuen Beitrag-Indikator war erfolgreich."); const timelineNode = document.querySelector('div[aria-label="Timeline: Your Home Timeline"]') || document.body; let mutationTimeout; const observer = new MutationObserver(() => { if (window.scrollY !== preservedScrollY) { window.scrollTo(0, preservedScrollY); console.log(`[Debug] Scroll-Position korrigiert auf: ${preservedScrollY}`); } clearTimeout(mutationTimeout); mutationTimeout = setTimeout(() => { observer.disconnect(); console.log(t("newPostsLoaded")); }, 3000); }); observer.observe(timelineNode, { childList: true, subtree: true }); setTimeout(() => { window.scrollTo(0, preservedScrollY); }, 100); } else { console.log("Kein klickbarer Indikator gefunden."); } } async function checkForNewPosts() { if (window.scrollY <= 10) { const newPostsIndicator = getNewPostsIndicator(); if (newPostsIndicator) { const preservedScrollY = window.scrollY; console.log("🆕 Neue Beiträge in der Nähe des oberen Randes erkannt. Klicke auf Indikator..."); clickNewPostsIndicator(newPostsIndicator, preservedScrollY); hasScrolledAfterLoad = false; try { console.log(t("contentLoadWait")); await waitForTimelineLoad(5000); console.log("Inhalt geladen, starte verfeinerte Suche..."); startRefinedSearchForLastReadPost(); } catch (error) { console.error("❌ Fehler beim Warten auf das Laden der Timeline:", error.message); console.log("[Debug] Starte Suche trotz Fehler."); startRefinedSearchForLastReadPost(); } } else { console.log("[Debug] Kein neuer Beitragsindikator gefunden."); } } else { console.log("[Debug] Scroll-Position nicht oben, keine neuen Beiträge geprüft."); } } async function waitForTimelineLoad(maxWaitTime = 3000) { const startTime = Date.now(); return new Promise((resolve) => { const checkInterval = setInterval(() => { const loadingSpinner = document.querySelector('div[role="progressbar"]') || document.querySelector('div.css-175oi2r.r-1pi2tsx.r-1wtj0ep.r-ymttw5.r-1f1sjgu'); const timeElapsed = Date.now() - startTime; if (!loadingSpinner) { console.log("[Debug] Ladeindikator verschwunden, resolve."); clearInterval(checkInterval); resolve(true); } else if (timeElapsed > maxWaitTime) { console.log("[Debug] Timeout erreicht, starte Suche trotz Ladeindikator."); clearInterval(checkInterval); resolve(true); } }, 200); }); } async function initializeScript() { console.log(t("pageLoaded")); try { await loadLastReadPostFromFile(); observeForNewPosts(); observeVisiblePosts(); window.addEventListener("scroll", debounce(() => { observeVisiblePosts(); if (!isAutoScrolling && !isSearching) { if (hasScrolledAfterLoad) { markTopVisiblePost(true); } else { hasScrolledAfterLoad = true; } } }, 500)); } catch (error) { console.error("❌ Fehler bei der Initialisierung des Skripts:", error); } } window.onload = async () => { if (!window.location.href.includes("/home")) { console.log(t("scriptDisabled")); return; } console.log(t("pageLoaded")); try { await loadNewestLastReadPost(); await initializeScript(); createButtons(); } catch (error) { console.error("❌ Fehler beim Seitenladen:", error); } }; window.addEventListener("blur", async () => { console.log(t("tabBlur")); if (lastReadPost && !downloadTriggered) { downloadTriggered = true; if (!(await isFileAlreadyDownloaded())) { console.log(t("downloadStart")); await downloadLastReadPost(); await markDownloadAsComplete(); } else { console.log(t("alreadyDownloaded")); } downloadTriggered = false; } }); window.addEventListener("focus", () => { isTabFocused = true; downloadTriggered = false; console.log(t("tabFocused")); }); async function isFileAlreadyDownloaded() { const localFiles = await GM_getValue("downloadedPosts", []); const fileSignature = `${lastReadPost.authorHandler}_${lastReadPost.timestamp}`; return localFiles.includes(fileSignature); } async function markDownloadAsComplete() { const localFiles = await GM_getValue("downloadedPosts", []); const fileSignature = `${lastReadPost.authorHandler}_${lastReadPost.timestamp}`; if (!localFiles.includes(fileSignature)) { localFiles.push(fileSignature); GM_setValue("downloadedPosts", localFiles); } } })();