// ==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-01-16.1
// @author Copiis
// @license MIT
// @match https://x.com/home
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_download
// ==/UserScript==
(function() {
'use strict';
let lastReadPost = null;
let isAutoScrolling = false;
let isSearching = false;
let isTabFocused = true;
let downloadTriggered = false;
let scrollTimeout;
let hasScrolledAfterLoad = false;
// Internationalization
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..."
},
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..."
}
};
const userLang = navigator.language.split('-')[0];
const t = (key) => translations[userLang]?.[key] || translations.en[key];
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 (!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) return;
console.log(t("searchStart"));
const popup = createSearchPopup();
let direction = 1; // 1 für nach unten, -1 für nach oben
let scrollAmount = 2000; // Startwert für Scroll-Sprünge
let previousScrollY = -1;
let lastComparison = null;
let lastDirection = direction; // Letzte Scrollrichtung speichern
function handleSpaceKey(event) {
if (event.code === "Space") {
console.log(t("searchCancel"));
isSearching = false;
popup.remove();
window.removeEventListener("keydown", handleSpaceKey);
}
}
window.addEventListener("keydown", handleSpaceKey);
function search() {
if (!isSearching) {
popup.remove();
return;
}
const comparison = compareVisiblePostsToLastReadPost(getVisiblePosts());
const lastReadTime = new Date(lastReadPost.timestamp);
let nearestVisiblePostTime = null;
const visiblePosts = getVisiblePosts();
if (visiblePosts.length > 0) {
nearestVisiblePostTime = new Date(visiblePosts[0].timestamp);
}
if (comparison === "match") {
const matchedPost = findPostByData(lastReadPost);
if (matchedPost) {
scrollToPostWithHighlight(matchedPost);
isSearching = false;
popup.remove();
window.removeEventListener("keydown", handleSpaceKey);
return;
}
} else if (comparison === "older") {
direction = -1;
} else if (comparison === "newer") {
direction = 1;
}
// Scroll-Sprünge anpassen basierend auf der Nähe zur gesuchten Position und der Scrollrichtung
if (direction !== lastDirection && nearestVisiblePostTime && Math.abs(lastReadTime - nearestVisiblePostTime) < 3600000) { // 3600000 ms = 1 Stunde
scrollAmount = Math.max(scrollAmount / 1.5, 100); // Bremsen, wenn die Richtung wechselt und die Zeitunterschied weniger als eine Stunde beträgt
} else {
scrollAmount = Math.min(scrollAmount * 1.5, 5000); // Andernfalls weiter beschleunigen
}
if (window.scrollY === previousScrollY) {
// Wenn kein Scrollen stattgefunden hat, ändern wir die Richtung
direction = -direction;
}
lastDirection = direction;
previousScrollY = window.scrollY;
window.scrollBy(0, direction * scrollAmount);
setTimeout(search, 300);
}
isSearching = true;
search();
}
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 15px 5px rgba(255, 223, 0, 0.9); transition: box-shadow 1.5s ease-in-out, transform 0.3s ease; border-radius: 12px; transform: scale(1.02);`;
post.scrollIntoView({ behavior: "smooth", block: "center" });
setTimeout(() => {
post.style.boxShadow = "0 0 0 0 rgba(255, 223, 0, 0)";
post.style.transform = "scale(1)";
}, 2000);
setTimeout(() => {
post.style.boxShadow = "none";
post.style.borderRadius = "unset";
isAutoScrolling = false;
console.log(t("highlightSuccess"));
}, 4500);
}
function getVisiblePosts() {
return Array.from(document.querySelectorAll("article")).map(post => ({
element: post,
timestamp: getPostTimestamp(post),
authorHandler: getPostAuthorHandler(post)
})).filter(post => post.timestamp && post.authorHandler); // Filter out posts without all data
}
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;`;
const buttons = [
{ icon: "📂", title: "Load saved reading position", onClick: importLastReadPost },
{ icon: "🔍", title: "Start manual search", onClick: startRefinedSearchForLastReadPost }
];
buttons.forEach(({ icon, title, onClick }) => {
const button = document.createElement("div");
button.style.cssText = `width: 36px; height: 36px; background: 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", () => {
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)")
);
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 });
}
async function checkForNewPosts() {
if (window.scrollY <= 10) {
const newPostsIndicator = getNewPostsIndicator();
if (newPostsIndicator) {
console.log("🆕 New posts detected near the top. Clicking indicator...");
clickNewPostsIndicator(newPostsIndicator);
hasScrolledAfterLoad = false;
console.log(t("contentLoadWait"));
await new Promise(resolve => setTimeout(resolve, 2000)); // Wait for 2 seconds for content to load
if (await simpleContentCheck()) {
console.log("Starting search after content has loaded...");
startRefinedSearchForLastReadPost();
} else {
console.warn("Content not fully loaded, proceeding anyway...");
startRefinedSearchForLastReadPost();
}
}
}
}
function getNewPostsIndicator() {
return document.querySelector('div[aria-label*="undefined"]');
}
function clickNewPostsIndicator(indicator) {
if (indicator) {
indicator.click();
console.log("Clicked on new posts indicator.");
}
}
async function simpleContentCheck() {
// A simpler check that might be more performant:
const posts = document.querySelectorAll("article");
return posts.length > 0; // Just check if there's any post loaded
}
window.onload = async () => {
if (!window.location.href.includes("/home")) {
console.log(t("scriptDisabled"));
return;
}
console.log(t("pageLoaded"));
await loadNewestLastReadPost();
await initializeScript();
createButtons();
window.addEventListener("beforeunload", async (e) => {
console.log("Browser wird geschlossen.");
if (lastReadPost && !downloadTriggered) {
downloadTriggered = true;
if (!(await isFileAlreadyDownloaded())) {
console.log(t("downloadStart"));
await downloadLastReadPost();
await markDownloadAsComplete();
} else {
console.log(t("alreadyDownloaded"));
}
}
});
};
async function initializeScript() {
console.log(t("pageLoaded"));
await loadLastReadPostFromFile();
observeForNewPosts();
window.addEventListener("scroll", () => {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
if (!isAutoScrolling && !isSearching) {
if (hasScrolledAfterLoad) {
markTopVisiblePost(true);
} else {
hasScrolledAfterLoad = true;
}
}
}, 500);
});
}
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);
}
}
})();