Twatter Feed Doctor

Cleans up the Twatter feeds

// ==UserScript==
// @name         Twatter Feed Doctor
// @namespace    http://tampermonkey.net/
// @version      2024-05-11
// @description  Cleans up the Twatter feeds
// @author       lolllllllllll
// @match        https://x.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=twitter.com
// @run-at       document-start
// @grant        none
// ==/UserScript==
(function() {let logEl;

//logEl = true

window.navigation.addEventListener("navigate", (event) => {
    //clogdebug(event);
    nextUrl = new URL(event.destination.url);
    checkUrlChange();
})

let prevUrl;
let nextUrl = window.location;
let debugMode = false;

checkUrlChange();

// Track deprecated function warnings to log only once
const deprecatedWarnings = new Set();

function checkUrlChange() {
    let decoded = decodeURI(nextUrl.href);
    if (decoded !== prevUrl) {
        setGlobals();
    }
    prevUrl = decoded;
}

function setGlobals() {
    if (nextUrl == null) nextUrl = window.location;
    let usp = new URLSearchParams(nextUrl.search);
    debugMode = usp.get("debug") === "1";
}

function observe(targetNode, onObserve, options) {
    let state = { abort: false };
    options ??= {};
    options.nodeTypes ??= [Node.ELEMENT_NODE];

    if (typeof targetNode === "string") {
        targetNode = document.querySelector(targetNode);
    }

    const callback = function (mutationsList, observer) {
        let _targetNode = targetNode;
        for (const mutation of mutationsList) {
            for (const element of mutation.addedNodes) {
                if (debugMode) console.log(element);
                if (!options.nodeTypes.includes(element.nodeType)) {
                    continue;
                }
                onObserve(mutation, element, state);

                if (state.abort) {
                    observer.disconnect();
                    return;
                }
            }
        }
    }

    const config = { attributes: false, childList: true, subtree: true };
    const observer = new MutationObserver(callback);
    observer.observe(targetNode, config);
}

function waitUntilScrolled(el, options) {
	return new Promise(res => {
		let observer = new IntersectionObserver(entries => {
			let sects = entries.filter(x => x.isIntersecting);
			if (sects.length > 0) {
				if (sects.length === 1) {
					res(sects[0].target);
				} else {
					res(sects.map(x => x.target));
				}

				observer.disconnect();
			}                
		}, options ?? { root: null, threshold: 0.5 });
		observer.observe(el);
	});
}

function waitUntilScrolled1(element, options = {}) {
	if (!(element instanceof Element)) {
		return Promise.reject(new Error('First argument must be a DOM element'));
	}

	const defaultOptions = {
		root: null,
		rootMargin: '0px',
		threshold: 0.5
	};

	const observerOptions = { ...defaultOptions, ...options };

	return new Promise((resolve, reject) => {
		try {
			const observer = new IntersectionObserver((entries) => {
				entries.forEach(entry => {
					if (entry.isIntersecting) {
						resolve(entry.target);
						observer.disconnect(); // Clean up immediately
					}
				});
			}, observerOptions);

			observer.observe(element);

			const timeout = setTimeout(() => {
				observer.disconnect();
				reject(new Error('Intersection observation timed out'));
			}, 30000);

			resolve.then(() => clearTimeout(timeout));
		} catch (error) {
			reject(new Error(`Observer creation failed: ${error.message}`));
		}
	});
}

function clogdebug(m) {
    if (debugMode) clog(m);
}

function clog(m) {
    if (typeof m === "object") {
		logClean(m);
        //console.log(m);
    } else {
		logClean(`${makeid(5)} ${m}`);
        //console.log(`${makeid(5)} ${m}`);
    }
}

function waitUntil(conditionFn, interval = 500, maxTries = 10) {
    return new Promise((resolve, reject) => {
        let attempts = 0;

        function checkCondition() {
            attempts++;
            let result;

            try {
                result = conditionFn();
            } catch (error) {
                console.warn(`Condition check threw an error: ${error.message}`);
            }

            if (result) {
                clogdebug(`Condition met after ${attempts} attempts`);
                resolve(result);
                return true;
            }

            if (attempts >= maxTries) {
                clogdebug(`Max tries (${maxTries}) exceeded`);
                reject(new Error(`Condition not met after ${maxTries} attempts`));
                return true;
            }

            clogdebug(`Attempt ${attempts}/${maxTries}: not true yet`);
            return false;
        }

        if (checkCondition()) return;

        const intervalId = setInterval(() => {
            if (checkCondition()) {
                clearInterval(intervalId);
            }
        }, interval);
    });
}

function _waitUntil(isTrue, interval, tries) {
    const id = makeid(5);

    function TryIt() {
        let ret;
        try {
            ret = isTrue();
            tries++;
        } catch { }

        if (!ret) console.log(`${id}: not true yet`);
        return ret;
    }

    var p = new Promise((resolve, reject) => {
        let ret = TryIt();
        if (ret) {
            resolve(ret);
        } else {
            var isTrueHandle = window.setInterval(function () {
                ret = TryIt();
                if (ret) {
                    window.clearInterval(isTrueHandle);
                    console.log(`${id}: cleared interval`);
                    resolve(ret);
                }
            }, interval || 500);
        }
    });

    return p;
}

function doEl(node, sel, onFound) {
    var els = node.querySelectorAll(sel);
    if (els) {
        els.forEach(el => {
            onFound(el);
        });
    }
}

function waitForElement(targetNode, sel, elementFound) {
    var els = targetNode.querySelectorAll(sel);
    if (els) {
        els.forEach(el => {
            elementFound(el);
        });
    }
    observe(targetNode, function (m, el, s) {
        var e;
        if (
            el.nodeName[0] !== "#" &&
            (el.matches(sel) || (e = el.querySelector(sel)))
        ) {
            elementFound(e ? e : el);
            return;
        }
    });
}

function displayNone(el) {
    if (!el) return;
    el.style.display = 'none';
}

function hideEl(el) {
    if (!el) return;
    el.style.visibility = 'hidden';
}

function dimEl(el, opacity) {
    el.style.opacity = opacity ?? "10%";
}

function makeid(length) {
    let result = '';
    const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    const charactersLength = characters.length;
    let counter = 0;
    while (counter < length) {
        result += characters.charAt(Math.floor(Math.random() * charactersLength));
        counter += 1;
    }
    return result;
}

function logClean(msg) {
    queueMicrotask(console.log.bind(console, msg));
}

Array.prototype.sum = function (selector) {
    if (this.length === 0) return 0;
    let sum = 0;
    this.forEach(x => sum += selector(x) ?? 0);
    return sum;
};

function isElementInViewport(element) {
    const rect = element.getBoundingClientRect();
    return (
        rect.top >= 0 &&
        rect.left >= 0 &&
        rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
        rect.right <= (window.innerWidth || document.documentElement.clientWidth)
    );
}

function forEachObjectEntry(obj, kvSelector) {
    let results = [];
    for (const [key, value] of Object.entries(obj)) {
        results.push(kvSelector(key, value));
    }
    return results;
}

function wiff(obj, wiffer) {
    return wiffer(obj);
}

function isNullish(value) {
    return value === null || value === undefined;
}

const TimeUnits = {
    millisecond: 1,
    second: 1000,
    minute: 1000 * 60,
    hour: 1000 * 60 * 60,
    day: 1000 * 60 * 60 * 24,
    year: 1000 * 60 * 60 * 24 * 365
};

function getTimeSpan(milliseconds) {
    // Handle negative values
    const isNegative = milliseconds < 0;
    const absMs = Math.abs(milliseconds);

    // Calculate fractional components using TimeUnits
    const years = absMs / TimeUnits.year;
    const days = absMs / TimeUnits.day; // Total days (fractional)
    const hours = absMs / TimeUnits.hour; // Hours in day (fractional)
    const minutes = absMs / TimeUnits.minute; // Minutes in hour (fractional)
    const seconds = absMs / TimeUnits.second; // Seconds in minute (fractional)
    const ms = absMs; // Milliseconds in second (fractional)

    // Return TimeSpan-like object
    return {
        // Fractional component properties
        years: isNegative ? -years : years,
        days: isNegative ? -days : days,
        hours: isNegative ? -hours : hours,
        minutes: isNegative ? -minutes : minutes,
        seconds: isNegative ? -seconds : seconds,
        milliseconds: isNegative ? -ms : ms,

        // Methods
        add: function (ms) {
            return TimeSpan(milliseconds + ms);
        },
        subtract: function (ms) {
            return TimeSpan(milliseconds - ms);
        },
        toString: function () {
            const sign = isNegative ? "-" : "";
            // Extract integer parts for formatting
            const d = Math.trunc(Math.abs(days));
            const h = Math.trunc(Math.abs(hours)).toString().padStart(2, '0');
            const m = Math.trunc(Math.abs(minutes)).toString().padStart(2, '0');
            const s = Math.trunc(Math.abs(seconds)).toString().padStart(2, '0');
            // Format milliseconds to three decimal places
            const ms = Math.abs(this.milliseconds).toFixed(3).padStart(7, '0');

            let result = `${sign}${h}:${m}:${s}.${ms}`;
            if (d > 0) {
                result = `${sign}${d}.${result}`;
            }
            return result;
        }
    };
}

function editLocalStorageObject(key, editor, defaultValueGetter = () => { }) {
    let obj = localStorage[key];
    obj = obj ? JSON.parse(obj) : defaultValueGetter();
    editor(obj);
    localStorage[key] = JSON.stringify(obj);
}

function sortMultiple(arr, comparers) {
    arr.sort((a, b) => {
        var i;
        for (const c of comparers) {
            i = c(a, b);
            if (i !== 0) return i;
        }
    });
}

function changeType(value, type) {
    if (value === undefined || value === null) return value;
    let vt = typeof value;
    if (vt === type) return value;

    let nv;
    switch (type) {
        case "string":
            nv = value.toString();
            break;
        case "boolean":
            nv =
                (vt === "number" && value === 1) ||
                value.toLowerCase() === "true" || value === "1";
            break;
        case "number":
            nv = parseFloat(value);
            break;
        default:
            nv = value;
    }
    return nv;
}

function ignoreError(action) {
    try {
        return { success: true, value: action() };
    } catch (e) {
        return { success: false, error: e };
    }
}

class AsyncLock {
    constructor() {
        this.locked = false;
        this.queue = [];
    }

    async acquire() {
        if (!this.locked) {
            this.locked = true;
            return;
        }
        await new Promise((resolve) => {
            this.queue.push(resolve);
        });
        this.locked = true;
    }

    release() {
        if (this.queue.length > 0) {
            const next = this.queue.shift();
            next();
        } else {
            this.locked = false;
        }
    }

    async executeLocked(callback) {
        await this.acquire();
        try {
            return await callback();
        } finally {
            this.release();
        }
    }
}

// Deprecated PascalCase wrappers - Cleanup target: June 2025
function CheckUrlChange() {
    if (!deprecatedWarnings.has("CheckUrlChange")) {
        console.log("Deprecated. use checkUrlChange instead");
        deprecatedWarnings.add("CheckUrlChange");
    }
    return checkUrlChange();
}

function SetGlobals() {
    if (!deprecatedWarnings.has("SetGlobals")) {
        console.log("Deprecated. use setGlobals instead");
        deprecatedWarnings.add("SetGlobals");
    }
    return setGlobals();
}

function DisplayNone(el) {
    if (!deprecatedWarnings.has("DisplayNone")) {
        console.log("Deprecated. use displayNone instead");
        deprecatedWarnings.add("DisplayNone");
    }
    return displayNone(el);
}

function HideEl(el) {
    if (!deprecatedWarnings.has("HideEl")) {
        console.log("Deprecated. use hideEl instead");
        deprecatedWarnings.add("HideEl");
    }
    return hideEl(el);
}

function DimEl(el, opacity) {
    if (!deprecatedWarnings.has("DimEl")) {
        console.log("Deprecated. use dimEl instead");
        deprecatedWarnings.add("DimEl");
    }
    return dimEl(el, opacity);
}

function DoEl(node, sel, onFound) {
    if (!deprecatedWarnings.has("DoEl")) {
        console.log("Deprecated. use doEl instead");
        deprecatedWarnings.add("DoEl");
    }
    return doEl(node, sel, onFound);
}

function LogClean(msg) {
    if (!deprecatedWarnings.has("LogClean")) {
        console.log("Deprecated. use logClean instead");
        deprecatedWarnings.add("LogClean");
    }
    return logClean(msg);
}

function ForEachObjectEntry(obj, kvSelector) {
    if (!deprecatedWarnings.has("ForEachObjectEntry")) {
        console.log("Deprecated. use forEachObjectEntry instead");
        deprecatedWarnings.add("ForEachObjectEntry");
    }
    return forEachObjectEntry(obj, kvSelector);
}

function ChangeType(value, type) {
    if (!deprecatedWarnings.has("ChangeType")) {
        console.log("Deprecated. use changeType instead");
        deprecatedWarnings.add("ChangeType");
    }
    return changeType(value, type);
}
;class SchemaVisitor {
    visit(value) {
        if (value === null || value === undefined) {
            return this.visitNullish();
        } else if (Array.isArray(value)) {
            return this.visitArray(value);
        } else if (typeof value === "string") {
            return this.visitString();
        } else if (typeof value === "number") {
            return this.visitNumber();
        } else if (typeof value === "boolean") {
            return this.visitBoolean();
        } else if (typeof value === "function") {
            return this.visitFunction();
        } else if (typeof value === "object") {
            return this.visitObject(value);
        }
        return {};
    }

    visitNullish() {
        return {};
    }

    visitString() {
        return { type: "string" };
    }

    visitNumber() {
        return { type: "number" };
    }

    visitBoolean() {
        return { type: "boolean" };
    }

    visitFunction() {
        return { type: "function" };
    }

    visitObject(obj) {
        const schemaEntry = { type: "object", properties: {} };
        for (const [key, value] of Object.entries(obj)) {
            schemaEntry.properties[key] = this.visit(value);
        }
        return schemaEntry;
    }

    visitArray(array) {
        const schemaEntry = { type: "array" };
        if (array.length === 0) {
            return schemaEntry;
        }

        let itemsType = null;
        for (const item of array) {
            const itemSchema = this.visit(item);
            if (itemSchema.type) {
                if (!itemsType) {
                    itemsType = itemSchema.type;
                    schemaEntry.items = { type: itemsType };
                    if (itemSchema.items) {
                        schemaEntry.items.items = itemSchema.items;
                    }
                    if (itemSchema.properties) {
                        schemaEntry.items.properties = itemSchema.properties;
                    }
                } else if (itemsType !== itemSchema.type) {
                    console.warn(`Array contains mixed types: expected ${itemsType}, found ${itemSchema.type}`);
                }
            }
        }
        return schemaEntry;
    }
}

function generateSchema(jsonObj) {
    if (typeof jsonObj !== "object" || jsonObj === null) {
        throw new Error("Input must be a non-null JSON object");
    }

    const visitor = new SchemaVisitor();
    const schema = {};
    for (const [key, value] of Object.entries(jsonObj)) {
        schema[key] = visitor.visit(value);
    }
    return schema;
}
;class FormEditor {
    constructor(schema, getData, setData) {
        this.schema = schema;
        this.getData = getData;
        this.setData = setData;
    }

    validateSchemaAndSettings(schema, settings, path = "") {
        for (const [key, spec] of Object.entries(schema)) {
            const fullPath = path ? `${path}.${key}` : key;
            if (!spec.type) {
                throw new Error(`Schema property '${fullPath}' has undefined type. Please manually set the type.`);
            }
            if (spec.type === "object") {
                if (!spec.properties) {
                    throw new Error(`Schema property '${fullPath}' with type 'object' must have a 'properties' field.`);
                }
                const nestedSettings = settings[key] || {};
                this.validateSchemaAndSettings(spec.properties, nestedSettings, fullPath);
            }
        }

        const schemaKeys = Object.keys(schema);
        const settingsKeys = Object.keys(settings);
        const missingInSchema = settingsKeys.filter(key => !schemaKeys.includes(key));
        if (missingInSchema.length > 0) {
            throw new Error(`Settings at '${path || "root"}' contains properties not defined in schema: ${missingInSchema.join(", ")}`);
        }
    }

    createListControl(key, spec, value, path = "") {
        const fullPath = path ? `${path}.${key}` : key;
        const container = document.createElement("div");
        container.className = `${fullPath}-options`;
        container.setAttribute("data-path", fullPath);
        if (spec.enableAdd) {
            const addInput = document.createElement("input");
            addInput.type = "text";
            addInput.placeholder = "Add new value";
            addInput.style.cssText = "margin-right: 10px; padding: 5px; background: #1c2b3a; color: #fff; border: 1px solid #38444d;";
            const addButton = document.createElement("button");
            addButton.textContent = "Add";
            addButton.style.cssText = "padding: 5px 10px; background: #1da1f2; color: #fff; border: none; border-radius: 4px; cursor: pointer;";
            addButton.onclick = () => {
                const newValue = addInput.value.trim();
                if (newValue && !spec.values.includes(newValue)) {
                    spec.values.push(newValue);
                    const newOption = this.createOption(newValue, spec, value, fullPath, container);
                    container.appendChild(newOption);
                    addInput.value = "";
                    if (spec.type === "string") {
                        newOption.querySelector("input").checked = true;
                        container.querySelectorAll("input").forEach(input => {
                            if (input !== newOption.querySelector("input")) input.checked = false;
                        });
                    }
                }
            };
            container.appendChild(addInput);
            container.appendChild(addButton);
        }
        spec.values.forEach(val => {
            const option = this.createOption(val, spec, value, fullPath, container);
            container.appendChild(option);
        });
        return container;
    }

    createOption(val, spec, value, key, container) {
        const option = document.createElement("div");
        option.style.cssText = "margin: 5px 0;";
        const input = document.createElement("input");
        input.type = spec.type === "array" ? "checkbox" : "radio";
        input.name = key;
        input.value = val;
        input.style.cssText = "margin-right: 5px;";
        if (spec.type === "array" && Array.isArray(value) && value.includes(val)) {
            input.checked = true;
        } else if (spec.type === "string" && value === val) {
            input.checked = true;
        }
        const label = document.createElement("label");
        label.textContent = val;
        label.style.cssText = "color: #fff; margin-right: 10px;";
        const deleteButton = document.createElement("button");
        deleteButton.textContent = "Delete";
        deleteButton.style.cssText = "padding: 2px 5px; background: #ff4d4f; color: #fff; border: none; border-radius: 4px; cursor: pointer;";
        deleteButton.onclick = () => {
            const index = spec.values.indexOf(val);
            if (index > -1) {
                spec.values.splice(index, 1);
                option.remove();
            }
        };
        option.appendChild(input);
        option.appendChild(label);
        option.appendChild(deleteButton);
        return option;
    }

    createTabControl(tabs, parentPath = "", activeTabIndex = 0) {
        const tabControl = document.createElement("div");
        tabControl.style.cssText = `
    margin-top: 10px; border: 1px solid #38444d; border-radius: 4px;
  `;

        const tabHeaders = document.createElement("div");
        tabHeaders.style.cssText = `
    background: #1c2b3a; border-bottom: 1px solid #38444d; overflow-x: auto;
  `;
  //      tabHeaders.style.cssText = `
  //  display: flex; background: #1c2b3a; border-bottom: 1px solid #38444d; overflow-x: auto;
  //`;

        const tabContents = document.createElement("div");
        tabContents.style.cssText = `
    padding: 10px; background: #15202b; position: relative;
  `;

        // Pre-render all tab contents upfront
        const tabContentElements = tabs.map((tab, index) => {
            const tabContent = document.createElement("div");
            tabContent.className = "tab-content-pane";
            tabContent.style.position = "absolute";
            tabContent.style.top = "0";
            tabContent.style.left = "0";
            tabContent.style.width = "100%";
            // Use opacity instead of visibility
            tabContent.style.opacity = index === activeTabIndex ? "1" : "0";
            tabContent.style.zIndex = index === activeTabIndex ? "1" : "0";
            tabContent.appendChild(this.createTabForm(tab.settings, tab.schema, tab.path));
            return tabContent;
        });

        // Calculate the maximum height of all tab contents
        let maxHeight = 0;
        const tempContainer = document.createElement("div");
        tempContainer.style.opacity = "0"; // Use opacity instead of visibility
        tempContainer.style.position = "absolute";
        document.body.appendChild(tempContainer);

        tabContentElements.forEach(tabContent => {
            tempContainer.appendChild(tabContent);
            const height = tabContent.offsetHeight;
            if (height > maxHeight) maxHeight = height;
            tempContainer.removeChild(tabContent);
        });

        document.body.removeChild(tempContainer);

        tabContents.style.height = `${maxHeight}px`;

        // Append all tab contents to the DOM
        tabContentElements.forEach(tabContent => {
            tabContents.appendChild(tabContent);
        });

        let tabColorActive = "#1c2b3a";
        let tabColorInactive = "#15202b";

        tabs.forEach((tab, index) => {
            const tabHeader = document.createElement("button");
            tabHeader.textContent = tab.name;
            tabHeader.style.cssText = `
      padding: 8px 16px; background: ${index === activeTabIndex ? tabColorActive : tabColorInactive};
      color: #fff; border: none; border-right: 1px solid #38444d; cursor: pointer;
      white-space: nowrap; flex-shrink: 0;
    `;
            tabHeader.onclick = (e) => {
                e.preventDefault();
                tabHeaders.querySelectorAll("button").forEach((btn, btnIndex) => {
                    btn.style.background = btnIndex === index ? tabColorActive : tabColorInactive;
                });
                tabContentElements.forEach((content, contentIndex) => {
                    content.style.opacity = contentIndex === index ? "1" : "0";
                    content.style.zIndex = contentIndex === index ? "1" : "0";
                });
            };
            tabHeaders.appendChild(tabHeader);
        });

        tabControl.appendChild(tabHeaders);
        tabControl.appendChild(tabContents);
        return tabControl;
    }

    createTabForm(settings, schema, path = "") {
        const container = document.createElement("div");
        container.className = "tab-content";
        container.style.cssText = `
    margin-left: 10px;display: flex; flex-direction: column; gap: 10px;
  `;

        const nonObjectEntries = [];
        const objectEntries = [];
        for (const [key, value] of Object.entries(settings)) {
            const spec = schema[key];
            if (spec.type === "object") {
                objectEntries.push([key, value, spec]);
            } else {
                nonObjectEntries.push([key, value, spec]);
            }
        }

        if (nonObjectEntries.length > 0) {
            const table = document.createElement("table");
            table.style.cssText = `
      width: 100%; border-collapse: collapse; color: #fff;
    `;

            for (const [key, value, spec] of nonObjectEntries) {
                const row = document.createElement("tr");

                const labelCell = document.createElement("td");
                labelCell.style.cssText = `
        padding: 5px 10px; text-align: right; font-weight: bold; vertical-align: middle; white-space: nowrap;
      `;
                const label = document.createElement("label");
                label.textContent = key;
                labelCell.appendChild(label);
                row.appendChild(labelCell);

                const controlCell = document.createElement("td");
                controlCell.style.cssText = `
        padding: 5px 10px; width: 100%; vertical-align: middle;
      `;

                const type = spec.type;
                let input;

                if (spec.values && spec.values.length > 0) {
                    input = this.createListControl(key, spec, value, path);
                } else {
                    const fullPath = path ? `${path}.${key}` : key;
                    switch (type) {
                        case "string":
                            input = document.createElement("input");
                            input.type = "text";
                            input.value = value;
                            input.setAttribute("data-path", fullPath);
                            input.style.cssText = `
              padding: 5px; background: #1c2b3a; color: #fff; border: 1px solid #38444d; border-radius: 4px; width: 100%; box-sizing: border-box;
            `;
                            break;
                        case "number":
                            input = document.createElement("input");
                            input.type = "number";
                            input.value = value;
                            input.step = "any";
                            input.setAttribute("data-path", fullPath);
                            input.style.cssText = `
              padding: 5px; background: #1c2b3a; color: #fff; border: 1px solid #38444d; border-radius: 4px; width: 100%; box-sizing: border-box;
            `;
                            break;
                        case "boolean":
                            input = document.createElement("input");
                            input.type = "checkbox";
                            input.checked = value;
                            input.setAttribute("data-path", fullPath);
                            break;
                        case "array":
                            input = document.createElement("input");
                            input.type = "text";
                            input.value = Array.isArray(value) ? value.join(", ") : value;
                            input.placeholder = "Comma-separated values (temporary)";
                            input.setAttribute("data-path", fullPath);
                            input.style.cssText = `
              padding: 5px; background: #1c2b3a; color: #fff; border: 1px solid #38444d; border-radius: 4px; width: 100%; box-sizing: border-box;
            `;
                            break;
                        case "function":
                            input = document.createElement("input");
                            input.type = "button";
                            input.value = "Execute";
                            input.addEventListener("click", () => {
                                value();
                            });
                            input.setAttribute("data-path", fullPath);
                            input.style.cssText = `
              padding: 5px; background: #1c2b3a; color: #fff; border: 1px solid #38444d; border-radius: 4px; width: 100%; box-sizing: border-box;
            `;
                            break;
                        default:
                            continue;
                    }
                }

                controlCell.appendChild(input);
                row.appendChild(controlCell);
                table.appendChild(row);
            }

            container.appendChild(table);
        }

        if (objectEntries.length > 0) {
            const tabs = objectEntries.map(([key, value, spec]) => ({
                name: key,
                settings: value || {},
                schema: spec.properties,
                path: path ? `${path}.${key}` : key
            }));
            const tabControl = this.createTabControl(tabs, path);
            container.appendChild(tabControl);
        }

        return container;
    }

    createForm() {
        const settings = this.getData();
        this.validateSchemaAndSettings(this.schema, settings);

        const editor = document.createElement("div");
        editor.className = "settings-editor";
        editor.style.cssText = `
      width: 100%; margin: 0; padding: 0; display: flex; flex-direction: column; height: 100%;
    `;

        const scrollableContainer = document.createElement("div");
        scrollableContainer.style.cssText = `
      flex: 1; overflow-y: auto; max-height: 60vh; padding-right: 5px;
    `;

        const tabForm = this.createTabForm(settings, this.schema);
        scrollableContainer.appendChild(tabForm);
        editor.appendChild(scrollableContainer);
        return editor;
    }

    collectSettings(editor, schema, path = "") {
        const settings = {};
        for (const [key, spec] of Object.entries(schema)) {
            const fullPath = path ? `${path}.${key}` : key;
            if (spec.type === "object") {
                settings[key] = this.collectSettings(editor, spec.properties, fullPath);
            } else {
                if (spec.values && spec.values.length > 0) {
                    const container = editor.querySelector(`[data-path="${fullPath}"]`);
                    if (!container) continue;
                    const inputs = container.querySelectorAll("input[type='radio'], input[type='checkbox']");
                    if (spec.type === "array") {
                        settings[key] = Array.from(inputs)
                            .filter(input => input.checked)
                            .map(input => input.value);
                    } else {
                        const checked = Array.from(inputs).find(input => input.checked);
                        settings[key] = checked ? checked.value : null;
                    }
                } else {
                    const input = editor.querySelector(`[data-path="${fullPath}"]`);
                    if (!input) continue;
                    switch (spec.type) {
                        case "string":
                            settings[key] = input.value;
                            break;
                        case "number":
                            settings[key] = parseFloat(input.value);
                            break;
                        case "boolean":
                            settings[key] = input.checked;
                            break;
                        case "array":
                            settings[key] = input.value.split(",").map(item => item.trim());
                            break;
                    }
                }
            }
        }
        return settings;
    }

    saveSettings(editor, options) {
        const newSettings = this.collectSettings(editor, this.schema);
        this.validateSchemaAndSettings(this.schema, newSettings);
        this.setData(newSettings, options);
    }

    showSettingsModal() {
        const modal = document.createElement("div");
        modal.style.cssText = `
      position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5);
      display: flex; justify-content: center; align-items: center; z-index: 1000;
    `;
        const modalContent = document.createElement("div");
        modalContent.style.cssText = `
      background: #15202b; padding: 20px; border-radius: 8px; max-width: 70%; width: 700px;
      display: flex; flex-direction: column; max-height: 80vh;
    `;

        const editor = this.createForm();
        modalContent.appendChild(editor);

        const buttonContainer = document.createElement("div");
        buttonContainer.style.cssText = `
      display: flex; justify-content: flex-end; gap: 10px; padding-top: 10px; border-top: 1px solid #38444d;
    `;

        const closeButton = document.createElement("button");
        closeButton.textContent = "Cancel";
        closeButton.style.cssText = `
      padding: 10px 20px; background: #38444d; color: #fff; border: none; border-radius: 4px; cursor: pointer;
    `;
        closeButton.onclick = () => modal.remove();

        const saveBtn = document.createElement("button");
        saveBtn.textContent = "Save";
        saveBtn.style.cssText = `
      padding: 10px 20px; background: #1da1f2; color: #fff; border: none; border-radius: 4px; cursor: pointer;
    `;
        saveBtn.onclick = () => {
            this.saveSettings(editor);
            modal.remove();
        };

        const saveBtnTemp = document.createElement("button");
        saveBtnTemp.textContent = "Save Temp";
        saveBtnTemp.style.cssText = `
      padding: 10px 20px; background: #1da1f2; color: #fff; border: none; border-radius: 4px; cursor: pointer;
    `;
        saveBtnTemp.onclick = () => {
            this.saveSettings(editor, { temp: true });
            modal.remove();
        };

        buttonContainer.appendChild(closeButton);
        buttonContainer.appendChild(saveBtn);
        buttonContainer.appendChild(saveBtnTemp);
        modalContent.appendChild(buttonContainer);

        modal.appendChild(modalContent);
        document.body.appendChild(modal);
        modal.onclick = (e) => {
            if (e.target === modal) modal.remove();
        };

        const scrollableContainer = editor.querySelector("div[style*='overflow-y: auto']");
        if (scrollableContainer) {
            scrollableContainer.addEventListener("wheel", (e) => {
                const atTop = scrollableContainer.scrollTop === 0;
                const atBottom = scrollableContainer.scrollTop + scrollableContainer.clientHeight >= scrollableContainer.scrollHeight;
                const scrollingUp = e.deltaY < 0;
                const scrollingDown = e.deltaY > 0;

                if ((atTop && scrollingUp) || (atBottom && scrollingDown)) {
                    e.preventDefault();
                }
            });
        }
    }

    createSettingsButton() {
        const template = document.querySelector("[data-testid='AppTabBar_More_Menu']") ||
            document.querySelector("[data-testid='AppTabBar_Profile_Link']");
        if (!template) {
            console.error("No sidebar template found!");
            return null;
        }
        const settingsBtn = template.cloneNode(true);
        settingsBtn.setAttribute("aria-label", "Settings");
        settingsBtn.setAttribute("data-testid", "AppTabBar_Settings_Link");
        if (settingsBtn.tagName === "A") {
            settingsBtn.href = "#";
        } else if (settingsBtn.tagName === "BUTTON") {
            settingsBtn.setAttribute("aria-expanded", "false");
            settingsBtn.setAttribute("aria-haspopup", "dialog");
        }
        const svg = settingsBtn.querySelector("svg");
        if (svg) {
            const path = svg.querySelector("path");
            path.setAttribute("d", "M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z");
        }
        const textSpan = settingsBtn.querySelector("span");
        if (textSpan) {
            textSpan.textContent = "Twat Doc Config";
        }
        return settingsBtn;
    }

    init(btnGetter) {
        const settingsBtn = this.createSettingsButton();
        if (!settingsBtn) return;

        settingsBtn.onclick = (e) => {
            e.preventDefault();
            try {
                this.showSettingsModal();
            } catch (e) {
                alert(`Error: ${e.message}`);
            }
        };
        btnGetter(settingsBtn);
    }
}

// Example usage
//let jsonObj = {
//    username: "bob",
//    maxPosts: 100,
//    darkMode: true,
//    filters: ["keyword1", "keyword2"],
//    config: {
//        allowX: true,
//        allowY: false,
//        someNumber: 33
//    },
//    nested: {
//        age: 30,
//        address1: {
//            street: "123 Main St",
//            city: "Anytown",
//            someOther: {
//                a: 1,
//                b: "2",
//                c: true
//            }
//        },
//        address2: {
//            street: "111 AA St",
//            city: "ZZZZZ",
//            someOther: {
//                a: 4,
//                b: "a",
//                c: false
//            }
//        }
//    }
//};

//const schema = generateSchema(jsonObj);
//const getData = () => jsonObj;
//const setData = (settings) => { jsonObj = settings };
//const editor = new FormEditor(schema, getData, setData);
//editor.init((btn) => {
//    const sidebar = document.querySelector("nav[role='navigation']");
//    if (sidebar) sidebar.appendChild(btn);
//});
;
(function() {
(async function () {
    'use strict';
    let curTs = new Date();
    //function waitUntilDOMContentLoaded() {
    //    return new Promise((resolve) => {
    //        if (document.readyState === 'interactive' || document.readyState === 'complete') {
    //            resolve();
    //        } else {
    //            document.addEventListener('DOMContentLoaded', () => resolve(), { once: true });
    //        }
    //    });
    //}

    //await waitUntilDOMContentLoaded(); // Wait for DOM

    //function waitUntilVisible() {
    //    return new Promise((resolve) => {
    //        if (document.visibilityState === 'visible') {
    //            resolve();
    //        } else {
    //            document.addEventListener('visibilitychange', () => {
    //                if (document.visibilityState === 'visible') resolve();
    //            }, { once: true });
    //        }
    //    });
    //}

    //await waitUntilVisible();

    // -----------------------------------
    // Backend: Configuration and Settings
    // -----------------------------------

    let settingsTemp;
    let settings;

    function GetDefaultSettings() {
        return {
            //names: [1,2,3],
            oldPostAge: 4,
            oldPostHideAge: 8,
            oldPostScoreThreshold: -10,
            hideSeenPostsAgeHours: 0.5,
            hideSeenPostsCount: 2,
            hidePostOverrideScoreThreshold: -20,
            hideOldPosts: false,
            hideBannedLangs: false,
            autoMuteScore: -30,
            debugMode: false,
            debugModeUi: false,
            enableDeletes: false,
            enableMutes: false,
            invertHide: false,
            enableTrendFilters: false,
            apiIntercept: {
                enabled: false,
                requests: {
                    enabled: true,
                    apiFeaturesIntercept: {
                        enabled: true,
                        features: {
                            rweb_video_screen_enabled: false,
                            profile_label_improvements_pcf_label_in_post_enabled: true,
                            rweb_tipjar_consumption_enabled: true,
                            verified_phone_label_enabled: false,
                            creator_subscriptions_tweet_preview_api_enabled: true,
                            responsive_web_graphql_timeline_navigation_enabled: true,
                            responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
                            premium_content_api_read_enabled: false,
                            communities_web_enable_tweet_community_results_fetch: true,
                            c9s_tweet_anatomy_moderator_badge_enabled: true,
                            responsive_web_grok_analyze_button_fetch_trends_enabled: false,
                            responsive_web_grok_analyze_post_followups_enabled: true,
                            responsive_web_jetfuel_frame: false,
                            responsive_web_grok_share_attachment_enabled: true,
                            articles_preview_enabled: true,
                            responsive_web_edit_tweet_api_enabled: true,
                            graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
                            view_counts_everywhere_api_enabled: true,
                            longform_notetweets_consumption_enabled: true,
                            responsive_web_twitter_article_tweet_consumption_enabled: true,
                            tweet_awards_web_tipping_enabled: false,
                            responsive_web_grok_show_grok_translated_post: false,
                            responsive_web_grok_analysis_button_from_backend: true,
                            creator_subscriptions_quote_tweet_preview_enabled: false,
                            freedom_of_speech_not_reach_fetch_enabled: true,
                            standardized_nudges_misinfo: true,
                            tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
                            longform_notetweets_rich_text_read_enabled: true,
                            longform_notetweets_inline_media_enabled: true,
                            responsive_web_grok_image_annotation_enabled: true,
                            responsive_web_enhance_cards_enabled: false
                        }
                    },
                },
                responses: {
                    enabled: true,
                }
            },
            visual: {
                enableContentClickEvent: true,
                betterContentTimestamps: true,
                largerContentInteractionButtons: false
            },
            tools: {
                triggerBreakpoint: function () {
                    debugger;
                },
                getAggregrateFeedUserCount: function() {                    
                    let groups = groupBy(Object.entries(xhrPosts), x => x[1].user.username);                    

                    groups = Object.entries(groups).map(x => ({ name: x[0], count: x[1].length })).sort((a, b) => a.count > b.count ? -1 : 1);

                    console.log(groups);
                },
                clearVisiblePostNonTweetText: function () {
                    let tmps = document.body.querySelectorAll("div[data-testid='cellInnerDiv']");
                    let seltweetText = "div[data-testid='tweetText']";

                    tmps.forEach(e => {
                        if (!isElementInViewport(e)) return;
                        let p = e.querySelector(seltweetText).parentElement;
                        let children = Array.from(p.parentElement.children);
                        let firstIndex = children.indexOf(p);

                        if (firstIndex < 0) return;

                        for (let i = firstIndex; i < children.length - 1; i++) {
                            let child = children[i];
                            if (!Array.from(child.children).some(x => x.matches(seltweetText))) child.remove();
                        }
                    });
                },
                clearVisiblePostBottomButtons: function () {
                    document.querySelectorAll("button[data-testid='reply']").forEach(x => {
                        let p = x.parentNode.parentNode;
                        if (!isElementInViewport(p)) return;

                        p.remove();
                    });
                },
                xhrpLookup: function () {
                    let id = prompt("Enter post ID or post URL")?.match("\\d+$");

                    if (!id) return;

                    let p = xhrPosts[id];

                    console.log(p);
                }
                //getListUserMenchies: function () {
                //    debugger;
                //    let ats = [];
                //    document.querySelector("div[aria-label='Timeline: List members']").querySelectorAll("div[data-testid^='UserAvatar'").forEach(x => {
                //        let link = x.querySelector("a[role='link']");
                //        ats.push("@" + link.getAttribute("href").substring(1));
                //    });
                //    console.log(ats.join(" "));
                //}
            }
        };
    }

    function deepMerge(target, source, options) {
        for (const key in source) {
            let targetHasProperty = target.hasOwnProperty(key);

            if (options?.deleteNonexistentTargetFromSource && !targetHasProperty) {
                delete source[key];
                continue;
            }

            let srcValue = source[key];

            if (!options?.mergeUndefined && srcValue === undefined) continue;
            if (options?.ignoreDefinedTarget && target[key] !== undefined) continue;
            if (options?.mergeOnlyExisting && !targetHasProperty) continue;

            if (typeof srcValue === "object" && !Array.isArray(srcValue)) {
                target[key] = deepMerge(target[key] || {}, srcValue, options);
            } else {
                target[key] = srcValue;
            }
        }
        return target;
    }

    const twatDocSettingsKey = "twatDocSettings";

    function getSettings() {
        let def = GetDefaultSettings();
        let newSettings = localStorage[twatDocSettingsKey];

        if (newSettings) {
            newSettings = JSON.parse(newSettings);

            newSettings = deepMerge(def, newSettings, { deleteNonexistentTargetFromSource: true });
        } else {
            newSettings = def;
        }

        return newSettings;
    }

    function saveSettings() {
        localStorage[twatDocSettingsKey] = JSON.stringify(settings);
    }

    settings = getSettings();

    // -----------------------------------
    // Backend: Data Models
    // -----------------------------------
    class RequestContext {
        constructor(request) {
            this.request = request;
            this.ts = new Date();
        }

        GetJson() {
            if (!this._json) {
                this._json = JSON.parse(this.request.responseText);
            }
            return this._json;
        }
    }

    /**
     * XhrPost and XhrUser are DTOs for X's proprietary objects. If their object structure changes, just account for
     * it in these constructors so that no other code changes are required elsewhere
     */
    class XhrPost {
        constructor() {
            this.info = [];
            this.deleteInfo = [];
            this.scores = [];
            //this.score = 0;
            this._muted = false;
        }

        addInfo(msg, getter) {
            this.info.push({ msg, getter: getter ?? (() => this.msg) });
        }

        get muted() {
            return this._muted || (this.score < 0 && this.score <= settings.autoMuteScore);
        }

        set muted(value) {
            this._muted = value;
        }

        setPropertiesByEntry(data) {
            this.data = data;
            let contentResult = getXhrPostContent(data).itemContent.tweet_results.result;
            this.contentMeta = this.getContentMeta(contentResult);
            this.subLocked = contentResult.cta?.title === "Subscribe to unlock";

            if (this.subLocked) {
                debugger;
                console.log(this.contentMeta);
                this.setPropertiesBySublocked(this.contentMeta);
            } else {
                this.setPropertiesByContentMeta(this.contentMeta);
            }
        }

        setPropertiesBySublocked(data) {
            this.virtualPost = this;
            this.text = this.contentMeta.text;
            this.postId = this.contentMeta.rest_id;
            this.user = {};
            this.user.username = this.contentMeta.core.user_results.result.legacy.screen_name;
            this.user.displayName = this.contentMeta.core.user_results.result.legacy.name;
        }

        setPropertiesByContentMeta(meta) {
            if (meta.tombstone) {
                this.tombstoned = true;
                return;
            }
            let post = meta.legacy;
            this.postId = post.id_str;
            this.text = post.full_text;
            this.lang = post.lang?.toLowerCase();
            this.bookmarks = post.bookmark_count;
            this.likes = post.favorite_count;
            this.views = parseFloat(meta.views.count);
            this.replies = post.reply_count;
            this.quotes = post.quote_count;
            this.reposts = post.retweet_count;
            this.replyToUsername = post.in_reply_to_screen_name;
            this.replyToUserId = post.in_reply_to_user_id_str;
            this.replyToPostId = post.in_reply_to_status_id_str;

            let ts = new Date(post.created_at);
            //let diff = Date.now() - createInfo.timestamp;

            //this.createInfo = { timestamp: new Date(post.created_at) };

            this.createInfo = getTimeSpan(curTs - ts);
            this.createInfo.timestamp = ts;

            //this.createInfo = getCreateInfo(post.created_at);

            this.user = new XhrUser(meta);
            this.likesToFollowers = this.likes / this.user.followers;
            this.viewsToFollowers = this.views / this.user.followers;
            this.likesToViews = this.likes / this.views;
            this.likesPerHour = this.likes / this.createInfo.hours;
            this.viewsPerHour = this.views / this.createInfo.hours;
            this.isQuote = post.is_quote_status;
            this.isRepost = !!post.retweeted_status_result;

            this.symbols = post.entities.symbols;

            if (this.isRepost) {
                this.repost = new XhrPost();
                this.repost.setPropertiesByContentMeta(this.getContentMeta(post.retweeted_status_result.result));
            }

            let result = meta.quoted_status_result?.result;

            if (this.isQuote && result) {
                this.quote = new XhrPost();
                result = result.tweet ?? result;
                this.quote.setPropertiesByContentMeta(result);
            }

            this.virtualPost = this.repost ?? this;
        }

        getContentMeta(result) {
            return result.tweet ?? result;
        }

        addScore(score, msg, getter) {
            this._score = undefined;
            this.scores.push({ score, msg, getter: getter ?? (() => this.msg) });
        }

        getScoreMessages(friendly) {
            if (this.score >= 0) return [];

            return this.scores.map(x => `${((friendly ? x.getter() : x.msg) ?? x.msg)} (${x.score})`);
        }

        _score;
        get score() {
            return this._score ??= this.scores.sum(x => x.score);
        }

        delete(msg) {
            this.deleteInfo.push(msg);
        }

        getInfoMessages(friendly) {
            return this.info.map(x => `${((friendly ? x.getter() : x.msg) ?? x.msg)}`);
        }

        getAllMessages(friendly) {
            return this.getScoreMessages(friendly).concat(this.getInfoMessages(friendly));
        }
    }

    class XhrUser {
        constructor(content) {
            let userMeta = content.core.user_results.result;
            let user = userMeta.legacy;
            this.username = user.screen_name;
            this.displayName = user.name;
            this.bio = user.description;
            this.location = user.location ?? userMeta.location?.location;
            this.isBlueVerified = userMeta.is_blue_verified;
            this.verified = user.verified;
            this.verifiedType = user.verified_type;
            this.isBusiness = this.verifiedType?.toLowerCase() === "business";
            this.followers = user.followers_count;
            this.follows = user.friends_count;
            this.following = user.following;
            this.blockedBy = user.blocked_by;
            this.muted = user.muting;
            this.isParody = user.parody_commentary_fan_label?.toLowerCase() === "parody";
            this.links = user.entities.description.urls.map(x => x.expanded_url);
        }
    }

    // -----------------------------------
    // Backend: XHR Interception
    // -----------------------------------
    let oldXHROpen = window.XMLHttpRequest.prototype.open;
    window.XMLHttpRequest.prototype.open = function (method, url, async, user, password) {

        //console.log(`xhr open: ${url}`);

        if (wiff(settings.apiIntercept, x => x?.enabled && wiff(x.requests, x => x?.enabled))) {
            arguments[1] = modifyQueryString(url);
        }

        this.addEventListener('readystatechange', function () {
            if (this.readyState === 4) {
                if (this.status !== 200) {
                    clogdebug(this.status);
                    if (!this.responseType || this.responseType === "text") clogdebug(this.responseText);
                    return;
                }
                let response = new RequestContext(this);
                handleResponse(response);
            }
        });

        return oldXHROpen.apply(this, arguments);
    };

    //await waitUntilDOMContentLoaded(); // Wait for DOM
    //await waitUntilVisible();

    function modifyQueryString(url) {

        try {
            const urlObj = new URL(url);
            const params = new URLSearchParams(urlObj.search);
            let dirty = false;

            let interceptSettings = settings.apiIntercept.requests.apiFeaturesIntercept;

            if (interceptSettings.enabled) {
                let featuresKey = "features";
                let features = params.has(featuresKey) && JSON.parse(params.get(featuresKey));

                if (features) {
                    deepMerge(features, interceptSettings.features, { mergeOnlyExisting: true });
                    params.set(featuresKey, JSON.stringify(features));
                    //params.set(featuresKey, JSON.stringify(interceptSettings.features));
                    dirty = true;
                }
            }

            if (dirty) {
                urlObj.search = params.toString();
                return urlObj.toString();
            } else {
                return url;
            }
        } catch (e) {
            console.error('Error modifying query string:', e);
            return url;
        }
    }

    // -----------------------------------
    // Backend: Data Processing
    // -----------------------------------
    let xhrPosts = {};
    let lastFeed = null;
    let feedUser = null;

    function handleResponse(requestContext) {

        //console.log(requestContext.request.responseURL);

        let feedPattern = `https://x.com/i/api/graphql/(\\w|-)+/(${Object.keys(feeds).join("|")})`;
        let urlMatch = requestContext.request.responseURL.match(feedPattern);

        if (urlMatch) {
            requestContext.feed = urlMatch[urlMatch.length - 1];
            //console.log(requestContext.request.responseURL);
            onFeedRequest(requestContext);
        }

        if (requestContext.isDirty === true) {
            Object.defineProperty(requestContext.request, 'response', { writable: true });
            Object.defineProperty(requestContext.request, 'responseText', { writable: true });
            requestContext.request.response = requestContext.request.responseText = JSON.stringify(requestContext.GetJson());
        }
    }

    // -----------------------------------
    // Backend: Feed Definitions
    // -----------------------------------
    const feeds = {
        HomeTimeline: null,
        HomeLatestTimeline: null,
        UserTweets: null,
        SearchTimeline: null,
        TweetDetail: null,
        GenericTimelineById: null,
        ExplorePage: null,
        CommunityTweetsTimeline: null,
        ListLatestTweetsTimeline: null
    };
    setKeyNames(feeds);

    const entryTypes = {
        TimelineTimelineItem: null,
        TimelineTimelineModule: null,
    }
    setKeyNames(entryTypes);

    const feedEntryIdTypes = {
        tweet: null,
        trend: null,
        eventsummary: null,
        stories: null,
        who_to_follow: null,
        home_conversation: null,
        cursor: null,
    };
    forEachObjectEntry(feedEntryIdTypes, (k,v) => feedEntryIdTypes[k] = k.replaceAll("_", "-"));

    const contentTypes = {
        TimelineTweet: null
    };
    setKeyNames(contentTypes);

    class FeedHandler {
        constructor(action, options) {
            this.action = action;
        }

        Handle(instructions) {

            return this.action(instructions);
        }
    }

    const bannedEntryIds = ["promoted-tweet", "community-to-join", /*"stories",*/ "toptabsrpusermodule", "who-to-subscribe", "who-to-follow", "toptabsrpusermodule", "recommended-recruiting-organizations"];
    const excludedFeeds = [feeds.TweetDetail, feeds.UserTweets, feeds.CommunityTweetsTimeline];

    function onFeedRequest(requestContext) {
        checkFeedChange(requestContext.feed);
        requestContext.lastXhrPostIndex = -1;

        //console.log(requestContext.feed);

        let dataGetter;
        switch (requestContext.feed) {
            case feeds.UserTweets: dataGetter = x => {
                let res = x.data.user.result;
                let instr = (res.timeline_v2 ?? res.timeline).timeline.instructions;
                return instr;
            };
                break;
            case feeds.SearchTimeline: dataGetter = x => x.data.search_by_raw_query.search_timeline.timeline.instructions; break;
            case feeds.TweetDetail: dataGetter = x => x.data.threaded_conversation_with_injections_v2.instructions; break;
            case feeds.GenericTimelineById: dataGetter = x => x.data.timeline.timeline.instructions; break;
            case feeds.ExplorePage: dataGetter = x => x.data.explore_page.body.initialTimeline.timeline.timeline.instructions; break;
            case feeds.CommunityTweetsTimeline: dataGetter = x => x.data.communityResults.result.ranked_community_timeline.timeline.instructions; break;
            case feeds.ListLatestTweetsTimeline: dataGetter = x => x.data.list.tweets_timeline.timeline.instructions; break;
            default: dataGetter = x => x.data.home?.home_timeline_urt.instructions;
        }

        let instr = dataGetter(requestContext.GetJson());
        let allEntries = instr.find(x => x.type === "TimelineAddEntries")?.entries;

        if (!allEntries || allEntries.length === 0) {
            clog("No TimelineAddEntries");
        } else {
            curTs = new Date();
            for (let i = allEntries.length - 1; i > -1; i--) {
                let entry = allEntries[i];
                requestContext.xhrIndex = i;
                if (i > requestContext.lastXhrPostIndex) requestContext.lastXhrPostIndex = i;

                let entryIdType = getFeedEntryIdType(entry);
                let bannedEntry = bannedEntryIds.find(x => entry.entryId.startsWith(x));

                if (bannedEntry) {
                    removeXhrEntry(i, allEntries, requestContext);
                    continue;
                }

                let isTimelineModule = entry.content.entryType === entryTypes.TimelineTimelineModule;
                let entries = [];
                let remove = false;

                switch (requestContext.feed) {
                    case feeds.ExplorePage:
                        //let remove = false;
                        switch (entryIdType) {
                            case feedEntryIdTypes.cursor:
                            case feedEntryIdTypes.trend:
                            case feedEntryIdTypes.tweet:
                                if (entryIdType === feedEntryIdTypes.tweet && isTimelineModule) {
                                    switch (entry.content.displayType) {
                                        case "Carousel":
                                            //entry.content.displayType = "Vertical";
                                            remove = true;
                                            break;
                                        case "Vertical":
                                            break;
                                    }
                                    //entry.content.itemContent.tweetDisplayType = "Tweet";                                    
                                }
                                break;
                            default:
                                remove = true;
                        }
                        break;
                    case feeds.GenericTimelineById:
                        break;
                    default:
                        if (entryIdType === feedEntryIdTypes.trend) {
                            remove = true;
                        }
                        break;
                }

                if (remove) {
                    //allEntries.splice(i, 1);
                    removeXhrEntry(i, allEntries, requestContext);
                    requestContext.isDirty = true;
                    continue;
                }

                if (entry.content.entryType === entryTypes.TimelineTimelineItem) {
                    entries = [entry];
                } else if (isTimelineModule) {
                    entries = [entry.content.items[entry.content.items.length - 1].item];
                }

                entries?.forEach(entry => {
                    let content = getXhrPostContent(entry);
                    if (content.itemContent.socialContext?.contextType === "Community") {
                        removeXhrEntry(i, allEntries, requestContext);
                        return;
                    }
                    onXhrPost(entry, allEntries, requestContext);
                });

                //entries?.forEach(entry => {
                //    processXhrPost
                //});
            }
        }
    }

    // -----------------------------------
    // Backend: Filters and Handlers
    // -----------------------------------
    const allowedLangs = ["en", "qam", "qct", "qht", "qme", "qst", "zxx", "art", "und"];
    //const bannedLangs = ["ar", "bn", "cs", "da", "de", "el", "_es", "fa", "fi", "fil", "fr", "he", "hi", "hu", "id", "it", "ja", "ko", "msa", "nl", "no", "pl", "pt", "ro", "ru", "sv", "th", "tr", "uk", "ur", "vi", "zh"];

    //🤷🤦🤼🤾🤽👶👦👧🧒👨👩🧑👱👴👵🧓👮👷💂🕵️👩‍⚕️👨‍⚕️🧑‍⚕️👩‍🌾👨‍🌾🧑‍🌾👩‍🍳👨‍🍳🧑‍🍳👩‍🎓👨‍🎓🧑‍🎓👩‍🎤👨‍🎤🧑‍🎤👩‍🏫👨‍🏫🧑‍🏫👩‍🏭👨‍🏭🧑‍🏭👩‍💻👨‍💻🧑‍💻👩‍💼👨‍💼🧑‍💼👩‍🔧👨‍🔧🧑‍🔧👩‍🔬👨‍🔬🧑‍🔬👩‍🎨👨‍🎨🧑‍🎨👩‍✈️👨‍✈️🧑‍✈️👩‍🚀👨‍🚀🧑‍🚀👩‍🚒👨‍🚒🧑‍🚒👰🤵👳🧕🙍🙎🙅🙆💁🙋🧏🙇💆💇🚶🧍🧎🏃💃🕺🧗🧘🤰🤱👩‍🍼👨‍🍼🧑‍🍼🧑‍🎄🎅🤶🧙🧝🧛🧜🧞🧟👼👋🤚🖐✋🖖👌🤌🤏✌️🤞🤟🤘🤙👈👉👆🖕👇☝️👍👎✊👊🤛🤜👏👐🙌🤲🤝🙏✍️💅🤳💪🏻

    const antiPattern = "(🚫|❌|no|anti)\\W*";

    function antify(s) {
        return `(?<!${antiPattern})${s}`;
        //return getRegexObject(s, null, x => `(?<!${antiPattern})${x}`);
    }

    function nonHashtagify(s) {
        return `\\b(?<!#)(${s})\\b`;
    }

    const filterActions = {
        hide: "hide",
        mute: "mute"
    };

    //❤️🤍💙 usa colors
    const filterDirectives = {
        emojies: [
            {
                values: ["🇮🇳", "🇪🇺", "🇮🇱", "☪️", "✡\uFE0F?", "🕎", "🏳️‍⚧️", "🏳️‍🌈", "🇵🇸", "🍉", "🇺🇦", "🟦(🟨|🟧)", "💙💛", "🟨⬜️🟪⬛️"].map(x => getRegexObject(antify(x), "u")),
                score: settings.autoMuteScore * 2
            },
            {
                values: [
                    "(🤷|✍|🫵|🏋|🙏|🤲|👇|🖐|💪|🤌|🙌|👍|🖕|👉|✊|👊|🫶|🫰)(🏾|🏿)",
                    "🟥⬛️🟩", "❤️🖤💚", "🔴⚫️🟢", //pan-african colors
                    "☭", "❤🧡💛💚💙💜", "🩵🩷🤍", "🦋(\\W*app)?"
                ].map(x => getRegexObject(x, "u")),
                score: -15
            },
            {
                values: [/(?<![💀🪦☠️🚫❌]\s*)💉(?!\s*[💀🪦☠️🚫❌])/u, /😷/u, /🌊/u, /🌻/u, /(?<!🏳️‍)🌈/u],
                score: -5
            },
            {
                values: [/🩷💜💙/u],
                score: settings.autoMuteScore / 3
            },
            {
                values: ["✝️", "🇺🇸"],
                score: 10
            },
        ],
        hashTags: [
            {
                values: [
                    "LGBTQ", "Pansexual", "panafrican(ism|ist)?s?", "blm", "BlackLivesMatter", "acab", "antifa", "Anti\\W*fascist", "StopCopCity", "NoJusticeNoPeace",
                    "voteblue", "bluewave", "BlueCrew", "fbr",
                    "(kamala|Harris)(tim|Walz)\\d*", "(kamalaharris|BidenHarris)\\d*", "ImWithHer", "StillWithHer", "kamala\\d+",
                    "fucktrump", "lovetrumpshate", "NeverTrump",
                    "NAFO", "Fella", "I?StandWithUkraine", "freePalestine",
                    "MyBodyMyChoice", "ClimateChange", "I?StandWithIsrael"
                ].map(x => `#${x}\\b`), score: settings.autoMuteScore
            },
            {
                values: ["progressive", "resist((er|or)s?|ance)?"].map(x => `#${x}\\b`), score: -20
            },
            {
                values: ["vote", "(en\\W*)?v\\W*tuber?"].map(x => `#${x}\\b`), score: -10
            },
            {
                values: [
                    "maga", "maha", "prolife", "1a", "2a",
                    /trump\d+/
                ].map(x => `#${x}\\b`), score: 15
            }
        ],
        pronounPatterns: [
            {
                values: [
                    { name: "theythem", pattern: wiff("(she|it|he|they|hers|her|his|him|them|xe|any)", x => `${x}(\\W*)${x}`) }, //(?!'\w)
                    wiff("(ele|dele|dela)", x => `${x}(\\W*)${x}`),
                    "any\\W*all", "any\\W*(prns|pronouns?)"
                ].map(x => {
                    if (typeof x === 'object') {
                        x.pattern = `\\b${x.pattern}\\b`
                        return x;
                    }
                    return `\\b${x}\\b`;

                }), score: -20
            }
        ],
        text: [
            {
                values: [
                    "never\\W*trump(er)?", "f(uck)?\\W*trump",
                    "(demi|homo|pan|bi|a)\\W*sexual", "demi(?!\\s+(moore|lovato))", "gender\\W*fluid", "non\\W*binary", "trans(gender)?", "ftm", "mtf", //poly
                    "furry",
                    "lgb(t?q?i?a?)?(\\W*ally)?", "(?<!fake\\W*and\\W*)gay", "queer", "lesbian", "fembo(y|i)", "gaymer",
                    "(bsky|bky)\\.social", "bluesky", "vote blue", "Free\\W*Palestine", "anti\\W*(racist|fascist|fash)", "pan(\\W*|_)african(ism|ists?)?",
                    "blm|Black\\W*Lives\\W*Matter", "antifa", "acab",
                    "resist(ance|or|er)?"
                ].map(x => getRegexObject(x, "i", x => nonHashtagify(x))), score: -10
            },
            {
                values: [
                    "autistic", "neuro\\W*divergent",
                    "democracy", "democrat", "progressive", "liberal",
                    "pro\\W*choice",
                    ["commun(ist|ism)", "social(ist|ism)", "femin(ist|ism)"].map(x => antify(x)),
                    "nafo",
                    "nazis?", "magats?",
                    "fasc(ists?|ism)", "rac(ists?|ism)", "sex(ists?|ism)", "misogyn(y|ism|(ist(ic|s?)))", "homophob(es?|ic|ia)", "transphob(es?|ic|ia)", "islamaphob(es?|ic|ia)",
                    "terfs?",
                    "blk",
                    //"pronouns",
                    "(en\\W*)?v\\W*tuber?"
                ].flat().map(x => getRegexObject(x, "i", x => nonHashtagify(x))).concat([getRegexObject(nonHashtagify("tRump"))]), score: -5
            },
        ]
    };

    const bannedLinkPatterns = [
        "\\w+\\.bsky\\.social", "bsky\\.app/profile/\\.+"
    ];

    const gimmickAccountPatterns = [
        "fights?", "videos?"
    ];

    /*
     fi - elveda cartoon network ağabey
     */
    const languages = {
        "fr": "French",
        "pt": "Portuguese",
        "es": "Spanish",
        "tr": "Turkish",
        "it": "Italian",
        "de": "German",
        "in": "Indonesian",
        "pl": "Polish",
        "da": "Danish",
        "nl": "Dutch",
        "ro": "Romanian",
        "ca": "Catalan",
        "sv": "Swedish",
        "cs": "Czech",
        "vi": "Vietnamese",
        "tl": "Tagalog",
        "hi": "Hindi",
        "mr": "Marathi",
        "sa": "Sanskrit",
        "ne": "Nepali",
        "kok": "Konkani",
        "bho": "Bhojpuri",
        "mai": "Maithili",
        "sd": "Sindhi",
        "pa": "Punjabi",
        "ja": "Japanese",
        "zh": "Chinese",
        "ko": "Korean",
        "ru": "Russian",
        "th": "Thai",
        "fa": "Persian",
        "ta": "Tamil",
        "iw": "Hebrew",
        "el": "Greek",
        "bn": "Bengali",
        "ar": "Arabic",
        "ur": "Urdu",
        "ps": "Pashto",
        "gu": "Gujarati",
        "te": "Telugu",
        "kn": "Kannada",
        "or": "Odia"
    };

    /*
    Because X regularly returns incorrect language codes (English users are oftentimes flagged as "fr", "de", etc),
    need to use the patterns below to test against the language text when language is not "en"
    99% of this is credited to Grok
    */

    /*
    language todo notes here
    */

    const langPatterns = [
        {
            langs: ["en"],
            patterns: [
                { pattern: /\b(the|what|this|that|these|those)\b/i, score: 100 },
                { pattern: /\b(I’m|you’re|he’s|she’s|it’s|we’re|they’re|I’ll|you’ll|he’ll|she’ll|it’ll|we’ll|they’ll)\b/i, score: 100 },
                { pattern: /\b(I|you|he|she|it|we|they|my|your|his|her|its|our|their|mine|yours|hers|ours|theirs|who|when)\b/i, score: 50 },
                { pattern: /\b(a|an|in|on|at|with|for|of)\b/i, score: 25 }
            ]
        },
        {
            langs: ["fr"], patterns: [
                { pattern: /\b(le|les|l'+\w|un|une|des|du|en|avec|je|tu|il|elle|nous|vous|ils|elles|mes|ta|tes|sa|ses|nos|ce|cet|cette|ces)\b|[éèêàçîô]/i, score: 100 },
                { pattern: /\b(de|la)\b/i, score: 25 } // "de" (French overlap), "la" (English "la")
            ]
        },
        {
            langs: ["pt"], patterns: [
                { pattern: /\b(ele|ela|nós|vós|eles|elas|meu|minha|teu|tua|nosso|nossa|este|esta|esse|essa|aquele|aquela|seu|sua|o que|quem|onde|este|essa|aquele|aquela)\b|[áéíóúâêôãõç]/i, score: 100 },
                { pattern: /\b(de|em)\b/i, score: 25 } // "de" (of), "em" (in) - common, overlap risks
            ]
        },
        {
            langs: ["es"], patterns: [
                { pattern: /\b(tú|él|ella|nosotros|vosotros|ellos|este|esta|qué|quién|dónde|te)\b|[áéíóúñ¿¡]/ig, score: 100 },
                { pattern: /\b(yo|que|de|la|en)\b/ig, score: 25 } // "yo" (pop culture), "que" (French), "de/la/en" (overlaps)
            ]
        },
        {
            langs: ["ro"],
            patterns: [
                { pattern: /\b(eu|cine|cel|cea|acest|această|nu)\b|[ăâîșț]/i, score: 100 },
                { pattern: /\b(tu|el|ea|noi|voi|ei|ele|meu|ta|al meu|al tău|al său)\b/i, score: 50 },
                { pattern: /\b(un|o|în|pe|cu|ce|îl)\b/i, score: 25 }
            ]
        },
        {
            langs: ["tr"], patterns: [
                { pattern: /\b(siz|onlar|ile|için|gibi|bir|nasıl|nerede|bu|şu)\b|[çğıöşü]/i, score: 100 },
                { pattern: /\b(i|ben|sen|de|ki)\b/i, score: 25 } // "i" (removed), "ben" (name), "sen" (Senate), "de" (French), "ki" (key)
            ]
        },
        {
            langs: ["it"], patterns: [
                { pattern: /\b(lui|lei|noi|voi|loro|gli|una|mio|miei|mie|tuo|tua|tuoi|suo|sua|suoi|nostr[oaie]|quest[oa]|quell[oa]|quei|quelle|che)\b|[àèéìòù]/i, score: 100 },
                { pattern: /\b(la|il|un|de)\b/i, score: 25 } // "la" (English), "il" (French), "un" (French), "de" (French)
            ]
        },
        {
            langs: ["de"], patterns: [
                { pattern: /\b(der|die|das|ein|eine|durch|für|um|aus|mit|zu|ich|du|er|sie|es|wir|ihr|mein|dein|sein|ihr|unser|dieser|diese|dieses|jener|jene|jenes)\b|[äöüß]/i, score: 100 },
                { pattern: /\b(in|den)\b/i, score: 25 } // "in" (English), "den" (English "den")
            ]
        },
        {
            langs: ["in"], patterns: [
                { pattern: /\b(saya|aku|kamu|dia|itu|di|ke|dari|apa)\b/i, score: 100 },
                { pattern: /\b(ini)\b/i, score: 50 } // "ini" (removed, Indonesian "this")
            ]
        },
        {
            langs: ["pl"], patterns: [
                { pattern: /\b(ja|ona|ono|wy|oni|ta|na|kto|gd\u017Aie)\b|[ąćęłńóśźż]/i, score: 100 },
                { pattern: /\b(to|ten)\b/i, score: 25 } // "to" (English), "ten" (English "ten")
            ]
        },
        {
            langs: ["da"], patterns: [
                { pattern: /\b(jeg|du|han|hun|vi|på|til|med|hvad|hvem|hvor|denne|dette|disse)\b|[æøå]/i, score: 100 },
                { pattern: /\b(den|det)\b/i, score: 25 } // "den" (English "den"), "det" (Czech "det")
            ]
        },
        {
            langs: ["nl"], patterns: [
                { pattern: /\b(het|een|ik|jij|hij|zij|wij|jullie|mijn|jouw|zijn|haar|ons|onze|dit|deze|wie|wanneer)\b|[éëí]/i, score: 100 },
                { pattern: /\b(de|van|in|op|met|we|hun|dat|die|wat)\b/i, score: 25 } // Removed Dutch terms: "de" (French), "van" (name), "in" (English), etc.
            ]
        },
        {
            langs: ["ca"], patterns: [
                { pattern: /\b(els|una|amb|ell|nosaltres|vosaltres|ells|meu|meva|teu|teva|seu|seva|nostre|nostra|aquest|aquesta|això|aquell|aquella|qui)\b|[àçèéíòóú]/i, score: 100 },
                { pattern: /\b(el|la|les|un|de|per|jo|tu|ella|què)\b/i, score: 25 } // Removed Catalan terms: "el" (Spanish), "la" (English), etc.
            ]
        },
        {
            langs: ["sv"], patterns: [
                { pattern: /\b(det|ett|om|på|för|mina|ditt|hennes|vår|vårt|våra|denna|detta|vad|vem|när)\b|[åäö]/i, score: 100 },
                { pattern: /\b(de|den|en|med|jag|du|han|hon|vi|ni|min|mitt|din|dina|hans)\b/i, score: 25 } // Removed Swedish terms: "de" (French), "en" (English), etc.
            ]
        },
        {
            langs: ["cs"], patterns: [
                { pattern: /\b(já|ty|on|ona|my|vy|oni|v|na|s|o|z|ten|ta|to|co|kdo|kdy)\b|[áčďéěíňóřšťúůýž]/i, score: 100 },
                { pattern: /\b(tady|že|ale)\b/i, score: 50 }
            ]
        },
        {
            langs: ["vi"], patterns: [
                { pattern: /\b(tôi|bạn|anh|chị|chúng|tôi|bạn|ở|với|này|nào|khi)\b|[ăâđêôơưàảãáạèẻẽéẹìỉĩíịòỏõóọùủũúụỳỷỹýỵ]/i, score: 100 },
                { pattern: /\b(ai|cho)\b/i, score: 25 } // "ai" (who), "cho" (for) - removed earlier
            ]
        },
        {
            langs: ["tl"], patterns: [
                { pattern: /\b(ako|ka|siya|kami|tayo|kayo|sila|sa|ng|kay|para|ito|iyan|iyon|ngayon|ano|sino)\b|[áéíóú]/i, score: 100 },
                { pattern: /\b(ang|isang|akin|iyo|kanya|amin|atin|inyo|kanila|kailan)\b/i, score: 50 } // Full table terms, overlap minimal
            ]
        },
        {
            langs: ["hi", "mr", "sa", "ne", "kok", "bho", "mai", "sd"], patterns: [
                { pattern: /[\u0900-\u097F]/u, score: 100 } // Devanagari (Hindi, Marathi, etc.)
            ]
        },
        {
            langs: ["pa"], patterns: [
                { pattern: /[\u0A00-\u0A7F]/u, score: 100 } // Gurmukhi (Punjabi)
            ]
        },
        {
            langs: ["ja", "zh"], patterns: [
                { pattern: /[一-龯ぁ-んァ-ヾー々]/u, score: 100 } // Japanese/Chinese chars
            ]
        },
        {
            langs: ["ko"], patterns: [
                { pattern: /[\uAC00-\uD7A3\u1100-\u11FF\u3131-\u318E\uA960-\uA97C\uD7B0-\uD7FB]/u, score: 100 } // Korean Hangul/Jamo
            ]
        },
        {
            langs: ["ru"], patterns: [
                { pattern: /[А-ЯЁ]/iu, score: 100 } // Cyrillic (Russian)
            ]
        },
        {
            langs: ["th"], patterns: [
                { pattern: /[\u0E00-\u0E7F]/u, score: 100 } // Thai
            ]
        },
        {
            langs: ["bn"],
            patterns: [{ pattern: /[\u0980-\u09FF]/u, score: 100 }]
        },
        {
            langs: ["ar"],
            patterns: [{ pattern: /[\u0621-\u064A\u0660-\u0669]/i, score: 100 }] // Keep narrow
        },
        {
            langs: ["fa", "ur", "ps"],
            patterns: [{ pattern: /[\u0600-\u06FF]/u, score: 100 }] // Broader
        },
        {
            langs: ["ta"], patterns: [
                { pattern: /[\u0B80-\u0BFF]/u, score: 100 } // Tamil
            ]
        },
        {
            langs: ["iw"], patterns: [
                { pattern: /[\u0590-\u05FF]/u, score: 100 } // Hebrew
            ]
        },
        {
            langs: ["el"], patterns: [
                { pattern: /[\u0370-\u03FF]/u, score: 100 } // Greek
            ]
        },
        {
            langs: ["gu"],
            patterns: [
                { pattern: /[\u0A80-\u0AFF]/u, score: 100 } // Gujarati script
            ]
        },
        {
            langs: ["te"],
            patterns: [
                { pattern: /[\u0C00-\u0C7F]/u, score: 100 } // Telugu script
            ]
        },
        {
            langs: ["kn"],
            patterns: [
                { pattern: /[\u0C80-\u0CFF]/u, score: 100 } // Kannada script
            ]
        },
        {
            langs: ["or"],
            patterns: [
                { pattern: /[\u0B00-\u0B7F]/u, score: 100 } // Odia script
            ]
        },
    ];

    for (const lp of langPatterns) {
        for (const p of lp.patterns) {
            p.pattern = getRegexObject(p.pattern, "g");
        }
    }

    class ContentHandler {
        constructor(action, feedsIncl, feedsExcl) {
            this.action = action;
            this.feedsIncl = feedsIncl;
            this.feedsExcl = feedsExcl;
            this.order = ContentHandler.lastOrder++;
        }

        static lastOrder = 0;

        Handle(postContext) {
            if ((this.feedsExcl ?? excludedFeeds)?.includes(postContext.requestContext.feed) === true &&
                this.feedsIncl?.includes(postContext.requestContext.feed) !== true) return;

            return this.action(postContext);
        }
    }

    /*
    Post handlers are executed against every post in onXhrPost(). Did it this way so that you can
    define what feeds each handler does or does not execute in
     */
    const contentHandlers = {
        //PostAge: new ContentHandler(function(context)  {
        //    let post = context.post.virtualPost;
        //    if (post.score >= settings.oldPostScoreThreshold) {
        //        let removePostOverride = post.createInfo.hours > settings.oldPostHideAge;
        //        if (post.createInfo.hours > settings.oldPostAge || removePostOverride) {
        //            if (removeOldPosts || removePostOverride) {
        //                let dateLabel = getTimeSummary(post.createInfo);
        //                return { msg: `${dateLabel} old` };
        //            }
        //        }
        //    }
        //}),
        //EngagementLock: new contentHandler(function (context) {
        //    let post = context.post.virtualPost;
        //    conversation_control.policy

//        {
//    "policy": "ByInvitation",
//    "conversation_owner_results": {
//        "result": {
//            "__typename": "User",
//            "legacy": {
//                "screen_name": "Whooptydoop"
//            }
//        }
//    }
//}

        //}),
        SubLock: new ContentHandler(function (context) {
            if (context.post.subLocked) {
                debugger; //these posts are hard to catch, so set up a hard breakpoint
                context.post.deleted = true;
                context.post.addInfo("Sublocked");
            }
        }),
        Business: new ContentHandler(function (context) {
            if (context.post.virtualPost.user.isBusiness) {
                context.post.virtualPost.muted = true;
                context.post.virtualPost.addInfo(context.post.virtualPost.user.verifiedType);
            }
        }),
        Symbols: new ContentHandler(function (context) {
            if (context.post.virtualPost.symbols?.length > 0) {
                context.post.virtualPost.deleted = true;
                context.post.virtualPost.addInfo("Symbols");
            }
        }),
        Parody: new ContentHandler(function (context) {
            if (context.post.user.isParody) {
                context.post.deleted = true;
                context.post.virtualPost.addInfo("Parody");
            }
        }),
        SearchTextInUsername: new ContentHandler(function (context) {
            let post = context.post.virtualPost;
            let dnSanitized = fldDisplayName.getter(post.user).replace(/\W/g, "");
            let un = fldUsername.getter(post.user);

            if (searchParamSanitized && (dnSanitized.toLowerCase().includes(searchParamSanitized) || un.toLowerCase().includes(searchParamSanitized))) {
                post.deleted = true;
                post.addInfo(`search query in name (${dnSanitized}/${un})`);
            }
        }, [feeds.SearchTimeline]),
        MutedBlocked: new ContentHandler(function (context) {
            let isFeedUser = feedUser?.username === context.post.virtualPost.user.username;
            if (isFeedUser) return;
            if (context.post.virtualPost.user.muted) {
                context.post.virtualPost.deleted = true;
                context.post.virtualPost.addInfo("Muted");
            }
        }, [feeds.UserTweets, feeds.CommunityTweetsTimeline], [feeds.TweetDetail]), //keep TweetDetail excluded until find a way to include in self replies
        VideoGimmick: new ContentHandler(function (context) {
            let post = context.post.virtualPost;
            if (gimmickAccountPatterns.some(x => getRegexObject(x).exec(post.user.username))) {
                post.deleted = true;
                post.addInfo("Video gimmick");
            }
        }),
        ViewLikeRatios: new ContentHandler(function (context) {
            let post = context.post.virtualPost;
            if (post.user.following) return;

            let cSettings = settings.contentHandlers[this.name].settings;

            let _msgRatio;
            function getRatio() {
                let msgs = [];
                if (!_msgRatio) {
                    msgs.push(`${post.likesToFollowers.toFixed(2)} like/follower ratio (${post.likes.toLocaleString()} / ${post.user.followers.toLocaleString()})`);
                    msgs.push(`${post.viewsToFollowers.toFixed(2)} view/follower ratio (${post.views.toLocaleString()} / ${post.user.followers.toLocaleString()})`);

                    if (cSettings.showExtendedContentMetrics) {
                        msgs.push(`${post.likesToViews.toFixed(2)} like/view ratio (${post.likes.toLocaleString()} / ${post.views.toLocaleString()})`);
                        msgs.push(`${post.likesPerHour.toFixed(2)} likes/hour ratio (${post.likes.toLocaleString()} / ${post.createInfo.hours.toLocaleString()}h)`);
                        msgs.push(`${post.viewsPerHour.toFixed(2)} views/hour ratio (${post.views.toLocaleString()} / ${post.createInfo.hours.toLocaleString()}h)`);
                    }

                    _msgRatio = msgs.join("\r\n");
                }
                return _msgRatio;
            }

            if (post.likes > 5000 && post.likesToFollowers >= 5) {
                let score;
                if (post.user.isBlueVerified || excludedFeeds?.includes(context.requestContext.feed) === true) {
                    post.doNotMute = true;
                }
                if (post.likesToFollowers > 15) score = -15;
                else if (post.likesToFollowers > 10) score = -10;
                if (score) {
                    post.addScore(
                        score,
                        getRatio()
                    );
                }
            } else if (cSettings.alwaysShowContentRatios) {
                post.addInfo(getRatio());
            }
        }, [feeds.TweetDetail, feeds.UserTweets]),
        BannedText: new ContentHandler(function (context) {
            let isFeedUser = feedUser?.username === context.post.user.username;
            //if (isFeedUser && !context.post.isRepost) return;
            let post = context.post.virtualPost;
            if (post.user.following) return;

            if (isFeedUser) {
                post.doNotMute = true;
            }

            let matches = getFieldMatches(post.user, userTextFieldGetters, bannedAll);
            matches = matches.filter(x => {
                //if not a pronoun pattern
                if (filterDirectives.pronounPatterns.includes(x.directive)) {
                    if (x.pattern.name === "theythem") {
                        let bits = [x.matchInfo.match[1].toLowerCase(), x.matchInfo.match[3].toLowerCase()];
                        if (bits[0] === bits[1]) return false;

                        let factor = 1;

                        let sep = x.matchInfo.match[2].trim();

                        if (!sep || /[&]/.exec(sep)) factor = factor / 2;

                        if (bits.includes("it")) factor = factor / 2;

                        x.score = x.directive.score * factor;
                    }
                }

                return true;
            });

            if (matches.length > 0) {
                //let score = 0;
                //post.addScore(matches.sum(x => x.directive.score));
                matches.forEach(x => {
                    let baseMsg = `${x.matchInfo.matchText} in ${x.field}`;
                    let getter = () => `${baseMsg} (${`${x.parts.before}${x.matchInfo.matchText}${x.parts.after}`.replace(/\r?\n|\r/g, " ")})`;

                    post.addScore(x.score ?? x.directive.score, baseMsg, getter);

                    if (x.directive.action === filterActions.hide) {
                        post.deleted = true;
                    }
                    //if (x.directive.score < 0) {
                    //    let baseMsg = `${x.matchInfo.matchText} in ${x.field}`;
                    //    let getter = () => `${baseMsg} (${`${x.parts.before}${x.matchInfo.matchText}${x.parts.after}`.replace(/\r?\n|\r/g, " ")})`;

                    //    post.addScore(x.score ?? x.directive.score, baseMsg, getter);
                    //} else {
                    //    post.addScore(x.score ?? x.directive.score);
                    //}
                });
            }
        }, [feeds.TweetDetail, feeds.CommunityTweetsTimeline, feeds.UserTweets]),
        BannedLinks: new ContentHandler(function (context) {
            let link = context.post.virtualPost.user.links?.find(x => bannedLinkPatterns.some(bl => x.match(bl)));

            if (link) {
                context.post.virtualPost.addScore(-20, link);
                //context.post.virtualPost.addInfo(link);
            }
        }),
        Lang: new ContentHandler(function (context) {
            let post = context.post.virtualPost;
            if (!allowedLangs.includes(post.lang)) {
                let msg = `Lang - ${post.lang}`;
                if (this.settings.hideBannedLangs === true) {
                    post.deleted = true;
                } else {
                    let score = 0;
                    let results = [];
                    let getters = [p => fldDisplayName.getter(p.user), p => fldBio.getter(p.user), p => p.text];
                    let langPattern = langPatterns.find(x => x.langs.includes(post.lang));
                    let maxScore = settings.contentHandlers[this.name].settings.languageConfidenceScore;

                    for (const g of getters) {
                        if (score >= maxScore) break;
                        let result = getLangScore(g(post), langPattern, maxScore);
                        if (result) {
                            score += result.totalScore;
                            results.push(result);
                        }
                    }

                    if (score >= maxScore) {
                        post.deleted = true;
                        let matches = results.map(x => x.matches).flat();
                        msg = `${msg} (${matches.map(m => `${m.match} (${m.score})`).join(", ")} - ${score})`;
                    }
                }
                post.addInfo(msg);
            }
        }, [feeds.CommunityTweetsTimeline]),
        SeenPosts: new ContentHandler(function (context) {
            if (isLiveSearch) return;

            let post = context.post.virtualPost;
            if (!seenPostTrackingEligible(post)) return;
            let seenPost = getSeenPostByFeed(context.requestContext.feed, post);

            /*if ((post.createInfo.hours >= settings.oldPostHideAge && seenPost.seenCount > 2) || seenPost.seenCount > 4) {*/
            if (post.createInfo.hours > settings.oldPostHideAge) {
                if (seenPost.seenCount > 0) post.deleted = true;
            }
            else if (post.seenInfo.seenHours >= settings.hideSeenPostsAgeHours && seenPost.seenCount > settings.hideSeenPostsCount) {
                post.deleted = true;
            }
            if (!post.seenInfo.isNew) post.addInfo(`Seen ${post.seenInfo.seenHours.toFixed(2)}h ago ${seenPost.seenCount} times; ${getTimeSummary(post.createInfo)} old`);
        }, null, [feeds.TweetDetail, feeds.UserTweets, feeds.HomeLatestTimeline, feeds.CommunityTweetsTimeline]),
        UserContent: new ContentHandler(function (context) {
            let xhrp = context.post;
            let cSettings = settings.contentHandlers[this.name].settings;

            if (xhrp.isRepost) {
                if (cSettings.allowRepostOfSelf === false && xhrp.repost.user.username === xhrp.user.username) {
                    xhrp.virtualPost.deleted = true;
                    xhrp.virtualPost.addInfo("Repost of self");
                } else if (cSettings.allowRepostOfQuoteOfSelf === false && xhrp.repost.isQuote && xhrp.repost.quote.user.username === xhrp.user.username) {
                    xhrp.virtualPost.deleted = true;
                    xhrp.virtualPost.addInfo("Repost of quote of self");
                } else if (cSettings.allowRepostOfReplyToSelf === false && xhrp.repost.replyToUsername === xhrp.user.username) {
                    xhrp.virtualPost.deleted = true;
                    xhrp.virtualPost.addInfo("Repost of reply to self");
                } else if (cSettings.allowRepostOfOther === false) {
                    xhrp.virtualPost.deleted = true;
                    xhrp.virtualPost.addInfo("Repost");
                }
            }

            if (xhrp.isQuote) {
                if (!xhrp.quote || xhrp.tombstoned) {
                    //consume the inner IF to prevent failover to ELSE
                    if (!cSettings.allowUnavailableQuotes) {
                        xhrp.deleted = true;
                        if (!xhrp.quote) {
                            xhrp.addInfo("Quote unavailable")
                        } else if (xhrp.tombstoned) {
                            xhrp.addInfo("Quote tombstoned")
                        }
                    }
                } else {
                    let isSelf = xhrp.quote.user.username === xhrp.user.username;
                    if (cSettings.allowQuoteOfSelf === false && isSelf) {
                        xhrp.deleted = true;
                        xhrp.addInfo("Quote of self")
                    } else if (cSettings.allowQuoteOfOther === false && !isSelf) {
                        xhrp.deleted = true;
                        xhrp.addInfo("Quote")
                    } else if (cSettings.allowQuoteOfReplyToSelf === false && xhrp.quote.replyToUsername === xhrp.user.username) {
                        xhrp.virtualPost.deleted = true;
                        xhrp.virtualPost.addInfo("Quote of reply to self");
                    } 
                }
            }
        }, undefined, [feeds.TweetDetail]),
        ColorCoding: new ContentHandler(function (context) {
            let post = context.post.virtualPost;
            if (isLiveSearch) return;
            post.colorCodeAge = true;
        }, null, [feeds.CommunityTweetsTimeline, feeds.HomeLatestTimeline, feeds.UserTweets, feeds.TweetDetail, feeds.ListLatestTweetsTimeline])
    };

    //note to users: this script auto-mutes accounts that have a low lib score, but leave it disabled. won't work unless you
    //have Twitter One Click Mute / Block anyway
    //https://chromewebstore.google.com/detail/twitter-one-click-mute-bl/gccnajdgjdianjclpcbpibilgkppeoko

    //settings.enableMutes = false; //leave it false for now

    if (!settings.contentHandlers) settings.contentHandlers = {};

    contentHandlers.Lang.settings = {
        languageConfidenceScore: 100,
        allowLangFailover: false,
        hideBannedLangs: false
    };

    contentHandlers.UserContent.settings = {
        allowRepostOfOther: true,
        allowRepostOfSelf: true,
        allowQuoteOfOther: true,
        allowQuoteOfSelf: true,
        allowRepostOfQuoteOfSelf: true,
        allowRepostOfReplyToSelf: true,
        allowQuoteOfReplyToSelf: true,
        allowUnavailableQuotes: false
    };

    contentHandlers.ViewLikeRatios.settings = {
        likeCountThreshold: 5000,
        showExtendedContentMetrics: false,
        alwaysShowContentRatios: false
    };

    contentHandlers.SeenPosts.settings = {
        maintenanceIntervalHours: 24,
        trackingHours: 48
    };

    forEachObjectEntry(contentHandlers, (k, v) => {
        //if no handler settings, assign empty object
        if (!v.settings) v.settings = {};

        let globalHandlerSettingsContainer = settings.contentHandlers[k];

        if (globalHandlerSettingsContainer === undefined || typeof globalHandlerSettingsContainer !== "object") {
            //if no global settings, merge handler defaults into global
            globalHandlerSettingsContainer = { settings: {} };
            settings.contentHandlers[k] = globalHandlerSettingsContainer;
        }

        //assign handler defaults to global. only set undefined properties to account for new properties
        //added later in development
        deepMerge(globalHandlerSettingsContainer.settings, v.settings, { ignoreDefinedTarget: true });

        if (globalHandlerSettingsContainer.settings.enabled === undefined) globalHandlerSettingsContainer.settings.enabled = true;

        //deepMerge(v.settings, settings.contentHandlers[k].settings);
        v.settings = settings.contentHandlers[k].settings;
        v.name = k;
    });

    function onXhrPost(entry, entries, requestContext) {
        let postContext;
        try {
            postContext = _onXhrPost(entry, entries, requestContext);
        } finally {
            if (postContext) {
                processXhrPost(postContext);
            }
        }
    }

    function _onXhrPost(entry, entries, requestContext) {
        if (getXhrPostContent(entry).itemContent.itemType !== contentTypes.TimelineTweet) {
            return;
        }

        let xhrp = new XhrPost();
        xhrp.setPropertiesByEntry(entry);

        if (!feedUser && requestContext.feed === feeds.UserTweets) {
            feedUser = xhrp.user;
        }

        const context = { post: xhrp, entry, entries, requestContext };

        xhrPosts[xhrp.postId] = xhrp;

        if (xhrp.repost) xhrPosts[xhrp.repost.postId] = xhrp.repost;

        let response;
        for (const key in contentHandlers) {
            if (!settings.contentHandlers[key]?.settings?.enabled) continue;
            let handler = contentHandlers[key];
            //if (settings.contentHandlers[key] !== true) continue;
            /*if (!handler.enabled) continue;*/
            response = handler.Handle(context);
            if (response?.exit || context.post.virtualPost.deleted) break;
        }

        if (xhrp.virtualPost.score < 0) {
            xhrp.virtualPost.addInfo(`Lib score: ${xhrp.virtualPost.score}`);
        }

        return context;
    }

    function processXhrPost(postContext) {
        let post = postContext.post.virtualPost;
        let nuked;

        function clogit(msg) {
            nuked = true;
            clogxhrpost(`${msg} - ${post.getAllMessages().join("; ")}`, postContext);
        }

        if (!isTypedSearch) {
            //prioritize mute over delete; keeps post in timeline for manual/auto mute
            if (post.muted) {
                clogit("Muted");
            } else if (post.deleted) {
                //don't delete posts with low score so that they show in the feed for manual/auto-mute
                if (post.score > settings.hidePostOverrideScoreThreshold) {
                    removeXhrEntry(postContext.requestContext.xhrIndex, postContext.entries, postContext.requestContext);
                    clogit("Removed");
                }
            }
        }

        //if (!nuked && post.score < 0) {
        //    let lastIndex = postContext.entries.length - 1;
        //    let lastEntry = postContext.entries[lastIndex];
        //    let thisEntry = postContext.entries[postContext.requestContext.xhrIndex];

        //    let index = lastEntry.sortIndex;

        //    lastEntry.sortIndex = thisEntry.sortIndex;
        //    thisEntry.sortIndex = index;

        //    //let temp = lastEntry;
        //    postContext.entries[lastIndex] = thisEntry;

        //    postContext.entries[postContext.requestContext.xhrIndex] = lastEntry;
        //    postContext.requestContext.isDirty = true;
        //}

        //if (!nuked && post.score < 0) {
        //    let temp = postContext.entries[postContext.requestContext.lastXhrPostIndex];
        //    postContext.entries[postContext.requestContext.lastXhrPostIndex] = postContext.entries[postContext.requestContext.xhrIndex];
        //    postContext.entries[postContext.requestContext.xhrIndex] = temp;
        //    postContext.requestContext.isDirty = true;
        //}
    }

    function removeXhrEntry(index, entries, requestContext) {
        if (!settings.enableDeletes) return;
        entries.splice(index, 1);
        requestContext.isDirty = true;
    }

    const fldUsername = { field: "username", getter: user => user.username };
    const fldDisplayName = { field: "displayName", getter: user => user.displayName };
    const fldLocation = { field: "location", getter: user => user.location };
    const fldBio = { field: "bio", getter: user => user.bio };
    const usernameGetters = [fldDisplayName, fldUsername];
    const userTextFieldGetters = [fldDisplayName, fldLocation, fldBio];
    const bannedAll = forEachObjectEntry(filterDirectives, (k, v) => v).reduce((acc, v) => acc.concat(v));

    // -----------------------------------
    // Frontend: DOM and UI Logic
    // -----------------------------------

    let contentSelectors = {
        cellInnerDiv: "div[data-testid='cellInnerDiv']",
        article: "article[role='article']",
        trend: "div[data-testid='trend']",
    };

    const contentSelector = "div[data-testid='cellInnerDiv']";
    const articleSelector = "article[role='article']";
    const bannedTrendCats = [
        "sports", "nfl", "football", "baseball", "soccer", "nhl", "hockey", "super bowl", "wwe", "Motorsport",
        "entertainment", "music",
        "bts"
    ].map(x => getRegexObject(`trending in ${x}|${x} · trending`, 'i'));
    const bannedTrends = [/\$\w+/].concat(bannedTrendCats).map(x => getRegexObject(x, "i"));

    let debugMode = false;
    let enabled = true;
    let isSearch;
    let isTypedSearch;
    let isLiveSearch;
    let isPostView;
    let removeOldPosts;
    let searchParam;
    let searchParamSanitized = false;
    let _isUserProfile = null;
    const postAges = [];
    const removedPostAges = [];
    const trendLock = new AsyncLock();

    class PostInfo {
        constructor(el) {
            this.el = el;
            this.elUsername = el.querySelector("div[data-testid='User-Name']");
            this.elHeader = this.elUsername.parentElement.parentElement.parentElement.parentElement;
            this.userDisplayName = this.elHeader.querySelector("a[role='link']").innerText;
            this.elContentLink = this.elUsername.querySelector("a[href*='/status/']");
        }
    }

    const settingsSchema = generateSchema(settings);

    // Initialize the editor
    const editor = new FormEditor(settingsSchema, () => settings, (s, options) => {
        settings = s;
        if (!options?.temp) {
            saveSettings();
        }
    });

    waitUntil(() => document.querySelector("nav[role='navigation']")).then(nav => {
        editor.init(btn => {
            //const sidebar = ;
            nav.appendChild(btn);
        });
    });

    function onContent(el) {
        let articleTweet = el.querySelector("article[data-testid='tweet']");
        if (articleTweet) onPost(el);

        //temp1.querySelector("div > div[data-testid='trend']")
        if (settings.enableTrendFilters) {
            let trend = el.querySelector(contentSelectors.trend);
            if (trend) onTrend(trend);
        }
    }

    async function onTrend(trend) {
        let mainNode = trend.firstElementChild; //parent of child components
        
        //let trendText = wiff(wiff(mainNode.childNodes[0].childNodes, x => x[x.length - 1]).innerText.split("·"), x => x && x.length === 2 ? x[0] : null);
        let trendText = wiff(mainNode.childNodes[0].childNodes, x => x[x.length - 1]).innerText;

        let hide = false;

        //hide = trendText && bannedTrendCats.find(x => x.exec(trendText));

        hide = trendText && bannedTrendCats.find(x => x.exec(trendText));

        if (!hide) {
            trendText = mainNode.childNodes[1].innerText;
            hide = bannedTrends.some(x => trendText.match(x));
        }

        if (hide) {
            async function HideTrend(target) {
                let btn = target.querySelector("button");
                if (!btn) return;
                btn.click();
                let elMenu = await waitUntil(() => document.body.querySelector("div[role='menu']"), 200);
                let x = await waitUntil(() => elMenu.querySelector("div[data-testid='Dropdown']"), 200);
                if (x) {
                    let removalOption;

                    removalOption = "Not interested in this";
                    //removalOption = "The associated content is not relevant";

                    let items = Array.from(x.childNodes);
                    let item = items.find(x => x.innerText === removalOption);

                    clog(`Removed trend ${trendText.replaceAll("\r", '').replaceAll("\n")}`);

                    item.click();
                }
            }
            waitUntilScrolled(trend).then(async trend => await trendLock.executeLocked(async () => HideTrend(trend)));
        }
    }

    function onPost(el) {
        let pi = new PostInfo(el);
        _onPost(el, pi);
    }

    function _onPost(el, pi) {
        pi.isUserProfile = isUserProfile();

        if (settings.visual.largerContentInteractionButtons) {
            var anchor = el.querySelector("button[data-testid='like']") ?? el.querySelector("button[data-testid='unlike']");
            let els = [...anchor.parentElement.parentElement.children]
                .forEach(child => child.querySelectorAll("*").forEach(x => {
                    let height = "20px";
                    x.style.lineHeight = height;
                    x.style.fontSize = height;
                }));
        }

        if (!feedUser && !enabled && !pi.isUserProfile) return;

        let postId = pi.elContentLink?.href.match("/(\\d+)$")[1];
        if (!postId) return;

        let xhrp = xhrPosts[postId];
        let info;

        if (xhrp) {
            info = xhrp.getAllMessages(true);
        } else if (settings.debugModeUi) {
            info = ["No xhr data"];
        }

        if (info?.length > 0) {
            let elInfo = document.createElement("div");
            let color;
            switch (true) {
                case xhrp?.score <= -20:
                    color = "red";
                    break;
                case xhrp?.score < 0:
                    color = "#ff7800";
                    break;
                default:
                    color = "yellow";
            }
            elInfo.style.border = `1px solid ${color}`;
            info.forEach(i => {
                var elInfoItem = document.createElement("div");
                elInfoItem.innerText = i;
                elInfo.appendChild(elInfoItem);
            });
            pi.elHeader.after(elInfo);
        }

        if (!xhrp) return;

        let scrollAction;

        if (settings.enableMutes && !xhrp.user.following && !xhrp.doNotMute && xhrp.muted) {
            scrollAction = () => waitUntil(() => el.querySelector("button[title='Mute']")).then(btn => btn.click());
        } else if (xhrp.seenInfo && seenPostTrackingEligible(xhrp)) {
            scrollAction = () => {
                xhrp.seenInfo.seenPost.seenCount++;
                updateSeenPost(xhrp);
            };
        }

        if (scrollAction) {
            if (isElementInViewport(el)) {
                scrollAction();
            } else {
                waitUntilScrolled(el).then(el => {
                    scrollAction();
                });
            }
        }

        //pi.article = el.querySelector("article");
        //pi.tweetTexts = el.querySelectorAll("div[data-testid='tweetText']");
        //pi.userProfileLink = pi.elContentLink.href.split("/status/")[0];
        //pi.udnSanitized = pi.userDisplayName.toLowerCase().replace(/[^a-zA-Z0-9]/gu, "");
        //pi.username = pi.userProfileLink.split("/")[1].toLowerCase();

        clogdebug(el);

        //let isParody = pi.elHeader.querySelector("a[href*='rules-and-policies/authenticity']");
        //let unavailable = pi.article?.querySelector("article")?.innerText === "This post is unavailable.";

        //if (unavailable) {
        //    removePost("unavailable", pi);
        //    return;
        //}

        //let elSocialContextLink = el.querySelector("span[data-testid='socialContext']")?.parentElement;

        //if (elSocialContextLink) {
        //    let socLinkText = elSocialContextLink.innerText;
        //    let isRepost = socLinkText.endsWith("reposted");
        //    let tweetTexts = el.querySelectorAll("div[data-testid='tweetText']");
        //    let isQuote = isRepost && tweetTexts.length === 2;
        //    let reposterLink = isRepost && elSocialContextLink.href.split("/status/")[0];
        //    let isSelfQuote = isQuote && pi.elContentLink.href.startsWith(reposterLink);

        //    if (isRepost && !isSelfQuote) {
        //        if (hideReposts) {
        //            removePost("Repost", pi);
        //            return;
        //        }
        //        if (!pi.isUserProfile) {
        //            if (isQuote) {
        //                let quotedUsername = tweetTexts[1].parentElement.parentElement.parentElement.parentElement
        //                    .querySelector("div[data-testid='User-Name'] > div:nth-child(2)")?.querySelector("div[dir='ltr']")?.innerText.substring(1);
        //                let quotedUserLink = `https://x.com/${quotedUsername}`;
        //                if (reposterLink === quotedUserLink) {
        //                    removePost(`Repost of quote of self (${reposterLink})`, pi);
        //                    return;
        //                }
        //            }
        //            if (pi.elContentLink.href.startsWith(reposterLink)) {
        //                removePost(`Repost of self (${reposterLink})`, pi);
        //                return;
        //            }
        //        }
        //    }
        //}

        //if (!pi.isUserProfile && elSocialContextLink?.matches("a[href*='/communities/']")) {
        //    removePost(`Community link (${elSocialContextLink.href})`, pi);
        //    return;
        //}

        if (settings.visual.betterContentTimestamps) {
            let elDate = pi.elContentLink.querySelector("time");

            elDate.innerText = getTimeSummary(xhrp.createInfo);
        }

        if (xhrp.colorCodeAge) {
            if (!isLiveSearch && !feedUser) {
                if (xhrp.createInfo.hours > settings.oldPostHideAge) {
                    el.style.backgroundColor = "#ff00003d";
                } else if (xhrp.createInfo.hours > settings.oldPostAge) {
                    el.style.backgroundColor = "#ff00002e";
                }
            }
        }

        //switch (xhrp.requestContext.feed) {
        //    case feeds.UserTweets:
        //    case feeds.TweetDetail:
        //        break;
        //    default:
        //        if (isLiveSearch || isFeedUser) break;
        //        if (xhrp.createInfo.hours > settings.oldPostHideAge) {
        //            el.style.backgroundColor = "#ff00003d";
        //        } else if (xhrp.createInfo.hours > settings.oldPostAge) {
        //            el.style.backgroundColor = "#ff00002e";
        //        }
        //}
    }

    function removePost(msg, pi) {
        if (isTypedSearch) return;
        displayNone(pi.el);
        clogpost(`Removed post - ${msg}`, pi);
    }

    function isUserProfile() {
        if (_isUserProfile === null) {
            _isUserProfile = !!document.body.querySelector("a[href*='/header_photo']");
        }
        return _isUserProfile;
    }

    // -----------------------------------
    // Utilities
    // -----------------------------------
    function setKeyNames(obj, handler) {
        handler = handler ?? ((key, value) => obj[key] = key);
        forEachObjectEntry(obj, handler);
    }

    function getFeedEntryIdType(entry) {
        return Object.keys(feedEntryIdTypes).find(x => entry.entryId.startsWith(x.replace("-", "_")));
    }

    function getXhrPostContent(data) {
        return data.content ?? data;
    }

    function checkFeedChange(feed) {
        if (lastFeed !== feed) {
            feedUser = null;
            lastFeed = feed;
        }
    }

    function getFieldMatches(entity, fields, directives) {
        if (!fields || fields.length === 0) fields = userTextFieldGetters;
        fields = fields.map(x => ({ field: x.field, value: x.getter(entity).replace(/\s+/g, ' ') }));

        let matchInfos = [];
        directives.forEach(dir => {
            let whole = dir.whole ?? false;
            let dirType = dir.type ?? 1;
            let options = dir.options ?? "i";

            function GetTextMatch(field, value) {
                let i = field.value.indexOf(value);
                if (i >= 0) return { matchText: value, matchIndex: i };
            }

            function GetRegExMatch(field, value) {
                let pattern;
                if (value instanceof RegExp) {
                    pattern = value;
                } else {
                    if (typeof value === 'object') {
                        value = value.pattern;
                    }
                    pattern = dir.whole ? getWholePattern(value) : value;
                    pattern = new RegExp(pattern, options);
                }

                let match = pattern.exec(field.value);

                if (match) return {
                    matchText: match[0],
                    match,
                    matchIndex: match.index,
                };
            }

            dir.values.forEach(value => {
                let getMatch = typeof value === 'string' && dirType === 0 && !whole ? GetTextMatch : GetRegExMatch;
                for (const field of fields) {
                    let matchInfo = getMatch(field, value);
                    if (matchInfo) {
                        let ret = {
                            field: field.field,
                            directive: dir,
                            pattern: value,
                            matchInfo
                        };
                        ret.parts = getSurroundingText(field.value, matchInfo.matchIndex, matchInfo.matchText.length + matchInfo.matchIndex, 10);
                        matchInfos.push(ret);
                        break;
                    }
                }
            });
        });
        return matchInfos;
    }

    function groupBy(array, keyOrSelector) {
        // First, group into an object as before
        //todo move condition outside
        const groupedObj = array.reduce((acc, item) => {
            const key = typeof keyOrSelector === 'function'
                ? keyOrSelector(item)
                : item[keyOrSelector];
            if (!acc[key]) {
                acc[key] = [];
            }
            acc[key].push(item);
            return acc;
        }, {});

        return groupedObj;
    }

    function groupBy1(array, keyOrSelector) {
        return array.reduce((acc, item) => {
            const key = typeof keyOrSelector === 'function'
                ? keyOrSelector(item)
                : item[keyOrSelector];
            if (!acc[key]) {
                acc[key] = [];
            }
            acc[key].push(item);
            return acc;
        }, {});
    }

    function getLangScore(text, langPattern, maxScore) {
        let matches = [];
        let totalScore = 0;
        let pattern;

        // Local function to process patterns
        function processPatterns(patterns, assignLangPattern = false) {
            for (const p of patterns) {
                if (totalScore >= maxScore) {
                    break;
                }

                p.lastIndex = 0;
                let match;

                while (totalScore < maxScore && (match = p.pattern.exec(text))) {
                    matches.push({ match: match[0], score: p.score });
                    totalScore += p.score;
                    if (assignLangPattern) pattern = p; // Only assign if generic case
                }
            }
        }

        if (langPattern) {
            processPatterns(langPattern.patterns);
        } else {
            langPatterns.filter(x => !x.langs).forEach(x => processPatterns(x.patterns, true));
        }

        if (totalScore > 0) return { langPattern, matches, totalScore };
    }

    function seenPostTrackingEligible(post) {
        //return !post.replyToPostId;
        return true;
    }

    function getSeenPostKey(feed, post) {
        let key = `${feed}_post${post.postId}`;

        return key;
    }

    function getSeenPostByFeed(feed, post) {
        post.seenInfo = {
            feed,
            key: getSeenPostKey(feed, post),
        };

        let seenPost = getSeenPostByKey(post.seenInfo.key);

        if (!seenPost) {
            post.seenInfo.isNew = true;
            seenPost = { seenCount: 0, firstSeen: curTs };
        }

        post.seenInfo.seenPost = seenPost;

        post.seenInfo.seenTime = curTs - seenPost.firstSeen;
        post.seenInfo.seenHours = post.seenInfo.seenTime / (1000 * 60 * 60);

        return seenPost;
    }

    function getSeenPostByKey(key) {

        let seenPost = localStorage[key];

        if (seenPost) {
            seenPost = JSON.parse(seenPost);
            seenPost.firstSeen = new Date(seenPost.firstSeen);
        }

        return seenPost;
    }

    function updateSeenPost(post) {
        if (!post.seenInfo) return;

        localStorage[post.seenInfo.key] = JSON.stringify(post.seenInfo.seenPost);
    }

    function waitUntil(condition, timeout = 5000) {
        return new Promise((resolve, reject) => {
            let start = Date.now();
            function check() {
                let result = condition();
                if (result) return resolve(result);
                if (Date.now() - start > timeout) return reject(new Error("Timeout"));
                setTimeout(check, 100);
            }
            check();
        });
    }

    //function getCreateInfo(ts) {
    //    let createInfo = {};
    //    createInfo.timestamp = new Date(ts);
    //    let diff = Date.now() - createInfo.timestamp;
    //    createInfo.minutes = diff / (1000 * 60);
    //    createInfo.hours = createInfo.minutes / 60;
    //    createInfo.days = createInfo.hours / 24;
    //    createInfo.years = createInfo.days / 365;
    //    return createInfo;
    //}

    function getTimeSummary(createInfo) {
        if (createInfo.hours < 1) return `${createInfo.minutes.toFixed(2)}m`;
        if (createInfo.days < 1) return `${createInfo.hours.toFixed(2)}h`;
        if (createInfo.years < 1) return `${createInfo.days.toFixed(2)}d`;
        return `${createInfo.years.toFixed(2)}y`;
    }

    function getWholePattern(pattern) {
        return `(?<=(^|\\W))(${pattern})(?=($|\\W))`;
    }

    let regexFlagMergeOptions = {
        mergeAll: 1,
        overwrite: 2
    };

    function getRegexObject(reginald, flags, transformer) {
        if (reginald instanceof RegExp) {
            if (!flags && !transformer) return reginald;
            //if (!transformer) return reginald;
            flags = Array.from(new Set(flags + reginald.flags)).join("");
            reginald = reginald.source;
            //flags = reginald.flags;
        } else {
            //if (flags) flags = Array.from(new Set(flags, reginald.flags)).join("");
        }

        if (transformer) reginald = transformer(reginald);

        return new RegExp(reginald, flags ?? "");
    }

    function getRegexObject1(reginald, flags, transformer, mergeOptions = 1) {
        let newFlags;
        if (reginald instanceof RegExp) {
            if (flags) {
                switch (mergeOptions) {
                    case 1:
                        newFlags = Array.from(new Set(flags, reginald.flags)).join("");
                        break;
                    case 2:
                        newFlags = flags;
                        break;
                }
            }

            if (!transformer && !newFlags) return reginald;

            reginald = reginald.source;
            if (!newFlags) newFlags = reginald.flags;
        } else {
            newFlags = flags;
        }

        if (transformer) reginald = transformer(reginald);

        return new RegExp(reginald, newFlags ?? "");
    }

    ///(\W|[_])/ this pattern also breaks on multi-byte unicode, so need to explicitly check for punctuation
    function getSurroundingText(s, start, end, padding, breakPattern = /[-\s.,\/#!$%^&*;:{}=_`~()]/) {
        breakPattern = getRegexObject(breakPattern);

        let parts = {};
        let o = (start - padding) + 1;

        for (; o > 0; o--) {
            if (s[o - 1].match(breakPattern)) {
                //if (o > 0) o++;
                break;
            }
        }

        parts.before = s.substring(o, start);

        let u = (end + padding) - 1;

        for (; u < s.length - 1; u++) {
            if (s[u + 1].match(breakPattern)) {
                //if (u === s.length - 2) u--;
                break;
            }
        }

        parts.after = s.substring(end, u + 1);

        return parts;
    }

    function clogxhrpost(m, postContext) {
        clog(`xhr - ${m} (https://x.com/${postContext.post.virtualPost?.user?.username}/status/${postContext.post.virtualPost?.postId})`);
    }

    function clogpost(m, pi) {
        if (seenPosts.includes(pi.elContentLink.href)) return;
        clog(`${m} (${pi.elContentLink.href})`);
    }

    function manageSeenPosts() {
        let msToHour = 1000 * 60 * 60;
        let lastSeenPostsCheckKey = "lastSeenPostsCheck";
        let lastCheck = localStorage[lastSeenPostsCheckKey] ? new Date(JSON.parse(localStorage[lastSeenPostsCheckKey])) : new Date(null);
        let elapsed = curTs - lastCheck;
        let trackingHours = msToHour * settings.contentHandlers.SeenPosts.settings.trackingHours;

        if (elapsed < settings.contentHandlers.SeenPosts.settings.maintenanceIntervalHours * msToHour) return;

        let removedItems = 0;
        let totalItems = 0;

        for (const key in localStorage) {
            if (!key.match("_post\\d+")) continue;
            totalItems++;
            let info = getSeenPostByKey(key);
            let elapsedItem = curTs - info.firstSeen;
            if (elapsedItem >= trackingHours) {
                removedItems++;
                localStorage.removeItem(key);
            }
        }

        localStorage[lastSeenPostsCheckKey] = JSON.stringify(curTs);
        clogStorage(`Removed ${removedItems}/${totalItems} seen post items`, `${lastSeenPostsCheckKey}Count`);
    }

    function clogStorage(msg, key) {
        clog(msg);
        localStorage[key] = msg;
    }

    // -----------------------------------
    // Main Execution
    // -----------------------------------
    let nextUrl;
    let prevUrl;
    window.navigation.addEventListener("navigate", (event) => {
        clogdebug(event);
        nextUrl = new URL(event.destination.url);
        checkUrlChange();
    });

    function checkUrlChange() {
        let decoded = decodeURI(nextUrl.href);
        if (decoded !== prevUrl) {
            clog(`Url nav: ${nextUrl.href}`);
            onUrlChange();
        }
        prevUrl = decoded;
    }

    const enabledPathNames = ["^/home", "^/search", "^/i/communities", "^/hashtag", "^/explore/tabs/for.you", "^/i/trending/\\w+", "/\\w+/status/\\d+"];
    let seenPostsPerUrl;

    function onUrlChange() {
        if (nextUrl == null) nextUrl = window.location;

        seenPostsPerUrl = [];

        let usp = new URLSearchParams(nextUrl.search);

        enabled = !!enabledPathNames.find(x => new RegExp(x).test(nextUrl.pathname));
        isSearch = !!nextUrl.pathname.match("/search") || !!nextUrl.pathname.match("/hashtag");
        isTypedSearch = usp.get("src") === "typed_query";
        isLiveSearch = usp.get("f") === "live";
        isPostView = !!nextUrl.href.match("https://x.com/\\w+/status/\\d+$");
        searchParam = usp.get("q");
        searchParamSanitized = searchParam?.toLowerCase().replace(/[^a-zA-Z0-9]/gu, "");
        debugMode = usp.get("debug") === "1";
        removeOldPosts = usp.has("rop");
        //hideReposts = usp.get("hrp") === "1";

        let qkvs = Array.from(usp.entries().map(kv => [kv[0].toLowerCase(), kv[1]]));
        forEachObjectEntry(settings, (k, v) => {
            let qs = qkvs.find(qkv => qkv[0] === k.toLowerCase());
            //let k = qkvs.map(qkv => ({
            //    qkv,
            //    m: qkv[0].match(`(\\$)?(${k.toLowerCase()})`)
            //})).find(m => m.qkv[0] === m.m?.[0]);

            if (qs) settings[k] = changeType(qs[1], typeof v);
        });
    }

    onUrlChange();
    manageSeenPosts();

    waitUntil(() => document.body).then(x => {
        doEl(x, contentSelector, onContent);
        observe(x, async (m, el) => {
            if (debugMode) console.log(el);
            let tmp;
            if (el.matches(contentSelector)) {
                onContent(el);
            } else if (el.matches("div") && (tmp = el.querySelector(`div > ${contentSelectors.trend}`))) {
                onTrend(tmp);
            } else if (el.matches(articleSelector) && el.innerText === "Thanks. X will use this to make your timeline better.") {
                displayNone(el.closest(contentSelector));
            } else if (ddHandler && el.tagName === "DIV" && el.className && el.parentElement.matches("div[role='menu']")) {
                let dd = el.querySelector("div:first-child > div:first-child[data-testid='Dropdown']");
                await handleDropdown(dd);
            }
        });
    });

    !settings.enableContentClickEvent && waitUntil(() => document.body?.querySelector('div#react-root')).then(x => {
        x.addEventListener('click', (e) => {
            //return causes the click to proceed as normal

            //check to see if the click occurred within a content template
            let template = e.target.closest("article[data-testid='tweet']");

            if (!template) return;

            let selRoleLink = "div[role='link'";
            let elQuote = template.querySelector(selRoleLink);

            //if so, check if it occurred within a quote
            if (elQuote && e.target.closest(selRoleLink)) {
                return;
            }

            //if not, check if it occurred on any of these selectors
            const interactiveSelectors = ['a', 'button', '[role="button"]', '[role="menu"]', '[role="menuitem"]', "div[role='radio']", 'svg', 'input', 'textarea'];
            let isInteractive = interactiveSelectors.some(x => e.target.matches(x) || e.target.closest(x));

            if (!isInteractive) {
                e.preventDefault();
                e.stopPropagation();
            }
        }, { capture: true });
    });

    let ddHandler;
    let ddOptionText;

    async function clickDropdownOption(btn, optionText, handler) {
        await trendLock.acquire();
        ddOptionText = optionText;
        ddHandler = handler;
        btn.click();
    }

    async function handleDropdown(dd) {
        try {
            if (!ddHandler) return;
            let _ddHandler = ddHandler;
            ddHandler = null;

            let items = dd.querySelectorAll("*[role='menuitem']");
            let item = Array.from(items).find(x => x.innerText.match(ddOptionText));

            if (item) {
                waitUntilScrolled(item).then(async item => {
                    item.click();
                    console.log(`${ddOptionText} clicked`);
                    try {
                        _ddHandler(ddOptionText, dd);
                    } finally {
                        ddOptionText = null;
                        await trendLock.release();
                    }
                });
            }
        } finally {
            // Handled in the WaitUntilScrolled callback
        }
    }
})();
})();})()