Romeo Additions

Enhances GR, especially for non-PLUS users

ของเมื่อวันที่ 19-06-2024 ดู เวอร์ชันล่าสุด

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name           Romeo Additions
// @name:de        Romeo Additions
// @namespace      https://greasyfork.org/en/users/723211-ray/
// @version        6.1.1
// @description    Enhances GR, especially for non-PLUS users
// @description:de Verbessert GR, insbesondere für nicht-PLUS-Benutzer
// @author         -Ray-, Djamana
// @match          *://*.romeo.com/*
// @license        MIT
// @supportURL     https://greasyfork.org/en/scripts/419514-romeo-additions
// ==/UserScript==

const CM2FT = 0.03280839895;
const KG2LBS = 2.20462262185;
const M2MI = 0.0006213712;

function escapeHtml(unsafe) {
    return unsafe
        .replace(/&/g, "&")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#039;");
}

function formatTime(str) {
    const date = new Date(Date.parse(str));
    const locale = document.documentElement.getAttribute("lang");
    return `${date.toLocaleDateString(locale)} ${date.toLocaleTimeString(locale)}`;
}

function isJson(value) {
    if (!value || typeof value !== "string") {
        return false;
    }
    try {
        JSON.parse(value);
        return true;
    } catch {
        return false;
    }
}

function log() {
    if (GM_info.script.version === "0.0.0") {
        if (arguments.length > 1 && arguments[1]) {
            arguments[0] += "\n";
        }
        console.log(...arguments);
    }
}

function round(value, maxDigits = 0) {
    const f = Math.pow(10, maxDigits);
    return Math.round(value * f) / f;
}

// ---- CSS ----

function addCss(css) {
    let style = document.createElement('style');
    if (style.styleSheet) {
        style.styleSheet.cssText = css;
    } else {
        style.appendChild(document.createTextNode(css));
    }
    return document.head.appendChild(style);
};

function setCssProp(name, value) {
    document.documentElement.style.setProperty(name, value);
}

// ---- DOM ----

const domHooks = {};

function hookDom() {
    function tagCall(el, callback) {
        if (!el.getAttribute("data-ra-hook")) {
            el.setAttribute("data-ra-hook", true);
            callback(el);
        }
    }

    const observer = new MutationObserver((mutations, observer) => {
        for (const mutation of mutations) {
            for (const el of mutation.addedNodes) {
                if (el.nodeType === Node.ELEMENT_NODE) {
                    for (const [selector, callback] of Object.entries(domHooks)) {
                        if (el.matches(selector)) {
                            // Trigger for element.
                            tagCall(el, callback);
                        } else {
                            // Trigger for children of attached elements.
                            for (const elChild of el.querySelectorAll(selector)) {
                                tagCall(elChild, callback);
                            }
                        }
                    }
                }
            }
        }
    });
    observer.observe(document.body, { subtree: true, childList: true });
}

function addElement(parent, html) {
    parent.insertAdjacentHTML("beforeend", html);
    return parent.lastChild;
}

function onElement(selector, callback) {
    // Trigger for existing elements.
    for (const el of document.querySelectorAll(selector)) {
        callback(el);
    }

    // Add to observer list.
    domHooks[selector] = callback;
}

// ---- Translation ----

const _strings = {
    aboutMe: {
        de: "Über mich",
        en: "About Me"
    },
    age: {
        de: "Alter",
        en: "Age"
    },
    ageRange: {
        de: "Altersspanne",
        en: "Age range"
    },
    ageRangeValue: {
        de: "Zwischen $from und $to",
        en: "Between $from and $to"
    },
    analPosition: {
        en: "Position"
    },
    analPosition_TOP_ONLY: {
        de: "Nur Aktiv",
        en: "Top only",
    },
    analPosition_MORE_TOP: {
        de: "Eher Aktiv",
        en: "More top",
    },
    analPosition_VERSATILE: {
        de: "Flexibel",
        en: "Versatile",
    },
    analPosition_MORE_BOTTOM: {
        de: "Eher Passiv",
        en: "More bottom",
    },
    analPosition_BOTTOM_ONLY: {
        de: "Nur Passiv",
        en: "Bottom only",
    },
    analPosition_NO: {
        de: "Kein Anal",
        en: "No anal",
    },
    beard: {
        de: "Bart",
        en: "Beard"
    },
    beard_DESIGNER_STUBBLE: {
        de: "3-Tage-Bart",
        en: "Designer stubble"
    },
    beard_FULL_BEARD: {
        de: "Vollbart",
        en: "Full beard"
    },
    beard_GOATEE: {
        en: "Goatee"
    },
    beard_MOUSTACHE: {
        de: "Schnauzer",
        en: "Moustache"
    },
    beard_NO_BEARD: {
        de: "Kein Bart",
        en: "No beard"
    },
    bedAndBreakfast: {
        en: "Bed & Breakfast"
    },
    bmi: {
        en: "BMI"
    },
    bmiMildThin: {
        de: "Leichtes Untergewicht",
        en: "Mildly Thin"
    },
    bmiModerateThin: {
        de: "Mäßiges Untergewicht",
        en: "Moderately Thin"
    },
    bmiNormal: {
        de: "Normal",
        en: "Normal"
    },
    bmiObese1: {
        de: "Adipositas I",
        en: "Obese Class I",
    },
    bmiObese2: {
        de: "Adipositas II",
        en: "Obese Class II",
    },
    bmiObese3: {
        de: "Adipositas III",
        en: "Obese Class III"
    },
    bmiPreObese: {
        de: "Präadipositas",
        en: "Pre-Obese"
    },
    bmiSevereThin: {
        de: "Starkes Untergewicht",
        en: "Severely Thin"
    },
    bodyType: {
        de: "Statur",
        en: "Body Type"
    },
    bodyType_ATHLETIC: {
        de: "Athletisch",
        en: "Athletic",
    },
    bodyType_AVERAGE: {
        de: "Normal",
        en: "Average"
    },
    bodyType_BELLY: {
        de: "Bauch",
        en: "Belly"
    },
    bodyType_MUSCULAR: {
        de: "Muskulös",
        en: "Muscular"
    },
    bodyType_SLIM: {
        de: "Schlank",
        en: "Slim",
    },
    bodyType_STOCKY: {
        de: "Stämmig",
        en: "Stocky"
    },
    bodyHair: {
        de: "Körperbehaarung",
        en: "Body Hair"
    },
    bodyHair_AVERAGE: {
        de: "Mittel behaart",
        en: "Hairy"
    },
    bodyHair_LITTLE: {
        de: "Wenig behaart",
        en: "Not very hairy"
    },
    bodyHair_SHAVED: {
        de: "Rasiert",
        en: "Shaved"
    },
    bodyHair_SMOOTH: {
        de: "Unbehaart",
        en: "Smooth"
    },
    bodyHair_VERY_HAIRY: {
        de: "Stark behaart",
        en: "Very hairy"
    },
    clearList: {
        de: "Möchtest du wirklich alle Einträge in der Liste entfernen?",
        en: "Do you really want to remove all elements from the list?"
    },
    commonOptions: {
        de: "Allgemeines",
        en: "Common"
    },
    concision: {
        de: "Beschneidung",
        en: "Concision"
    },
    concision_CUT: {
        de: "Beschnitten",
        en: "Cut"
    },
    concision_UNCUT: {
        de: "Unbeschnitten",
        en: "Uncut"
    },
    customRadius: {
        de: "Benutzerdefinierter Radius",
        en: "Custom Radius"
    },
    dick: {
        de: "Schwanz",
        en: "Dick"
    },
    dick_S: {
        en: "S"
    },
    dick_M: {
        en: "M"
    },
    dick_L: {
        en: "L"
    },
    dick_XL: {
        en: "XL"
    },
    dick_XXL: {
        en: "XXL"
    },
    dirty: {
        en: "Dirty"
    },
    dirty_NO: {
        de: "Kein Dirty",
        en: "No dirty"
    },
    dirty_WS_ONLY: {
        de: "Ja, aber nur NS",
        en: "WS only"
    },
    dirty_YES: {
        en: "Dirty"
    },
    display: {
        de: "Anzeige",
        en: "Display"
    },
    distance: {
        de: "Entfernung",
        en: "Distance"
    },
    enhancedFilter: {
        de: "Filter mit neuen Optionen",
        en: "Filter with new options"
    },
    enhancedFilterDesc: {
        de: "Erlaubt den Filter von Radar-Ergebnissen mit zusätzlichen Infos.",
        en: "Allows filters in radar results with additional infos."
    },
    enhancedTiles: {
        de: "Profildetails auf Kacheln",
        en: "Grid stats"
    },
    enhancedTilesDesc: {
        de: "Zeigt folgende Details auf Kacheln. Der Radar nutzt dafür große Benutzerkacheln.",
        en: "Shows the following details on tiles. The radar uses large user tiles for this."
    },
    ethnicity: {
        de: "Typ",
        en: "Ethnicity"
    },
    ethnicity_ARAB: {
        de: "Araber",
        en: "Arab"
    },
    ethnicity_ASIAN: {
        de: "Asiate",
        en: "Asian"
    },
    ethnicity_BLACK: {
        de: "Schwarz",
        en: "Black"
    },
    ethnicity_CAUCASIAN: {
        de: "Europäer",
        en: "Caucasian"
    },
    ethnicity_INDIAN: {
        de: "Inder",
        en: "Indian"
    },
    ethnicity_LATIN: {
        de: "Latino",
        en: "Latin"
    },
    ethnicity_MEDITERRANEAN: {
        de: "Südländer",
        en: "Mediterranean"
    },
    ethnicity_MIXED: {
        en: "Mixed"
    },
    eyeColor: {
        de: "Augenfarbe",
        en: "Eye Colour"
    },
    eyeColor_BLUE: {
        de: "Blau",
        en: "Blue"
    },
    eyeColor_BROWN: {
        de: "Braun",
        en: "Brown"
    },
    eyeColor_GREEN: {
        de: "Grün",
        en: "Green"
    },
    eyeColor_GREY: {
        de: "Grau",
        en: "Grey"
    },
    eyeColor_OTHER: {
        de: "Sonstige",
        en: "Other"
    },
    fetish: {
        de: "Fetisch",
        en: "Fetish"
    },
    fetish_BOOTS: {
        en: "Boots"
    },
    fetish_CROSSDRESSING: {
        de: "Cross-Dressing",
        en: "Cross-dressing"
    },
    fetish_DRAG: {
        de: "Dessous",
        en: "Lingerie"
    },
    fetish_FORMAL: {
        de: "Anzug",
        en: "Formal dress"
    },
    fetish_JEANS: {
        en: "Jeans"
    },
    fetish_LEATHER: {
        de: "Leder",
        en: "Leather"
    },
    fetish_LYCRA: {
        en: "Lycra"
    },
    fetish_RUBBER: {
        en: "Rubber"
    },
    fetish_SKATER: {
        en: "Skater"
    },
    fetish_SKINS: {
        en: "Skins & Punks"
    },
    fetish_SNEAKERS: {
        en: "Sneakers & Socks"
    },
    fetish_SPORTS: {
        de: "Sportsgear",
        en: "Sports gear"
    },
    fetish_TECHNO: {
        en: "Raver",
    },
    fetish_UNDERWEAR: {
        de: "Unterwäsche",
        en: "Underwear"
    },
    fetish_UNIFORM: {
        en: "Uniform"
    },
    fetish_WORKER: {
        de: "Handwerker",
        en: "Worker"
    },
    filters: {
        en: "Filters",
        de: "Filter"
    },
    fisting: {
        de: "Fisten",
        en: "Fisting"
    },
    fisting_ACTIVE: {
        de: "FF Aktiv",
        en: "FF Active",
    },
    fisting_ACTIVE_PASSIVE: {
        de: "FF Flexibel",
        en: "FF Versatile",
    },
    fisting_NO: {
        de: "Kein FF",
        en: "No FF"
    },
    fisting_PASSIVE: {
        de: "FF Passiv",
        en: "FF Passive",
    },
    fullHeadlines: {
        de: "Vollständige Überschriften",
        en: "Full headlines"
    },
    fullHeadlinesDesc: {
        de: "Zeigt auch lange Profilüberschriften ungekürzt auf Kacheln.",
        en: "Shows even long profile headlines completely on tiles."
    },
    fullMessages: {
        de: "Vollständige Nachrichten",
        en: "Full messages"
    },
    fullMessagesDesc: {
        de: "Zeigt Nachrichten ungekürzt in der Nachrichtenliste.",
        en: "Shows messages without truncation in the message list."
    },
    gender: {
        de: "Geschlecht",
        en: "Gender"
    },
    gender_MAN: {
        de: "Mann",
        en: "Man"
    },
    gender_TRANS_MAN: {
        de: "Transmann",
        en: "Trans man"
    },
    gender_TRANS_WOMAN: {
        de: "Transfrau",
        en: "Trans woman"
    },
    gender_NON_BINARY: {
        de: "Nicht binär",
        en: "Non-binary"
    },
    gender_OTHER: {
        de: "Anderes",
        en: "Other"
    },
    genderOrientation: {
        de: "Ich bin",
        en: "I am"
    },
    general: {
        de: "Allgemein",
        en: "General"
    },
    hairColor: {
        de: "Haarfarbe",
        en: "Hair Colour"
    },
    hairColor_BLACK: {
        de: "Schwarz",
        en: "Black"
    },
    hairColor_BLOND: {
        en: "Blond"
    },
    hairColor_BROWN: {
        de: "Braune Haare",
        en: "Brown"
    },
    hairColor_GREY: {
        de: "Grau",
        en: "Grey"
    },
    hairColor_LIGHT_BROWN: {
        de: "Dunkelblond",
        en: "Light brown"
    },
    hairColor_OTHER: {
        de: "Sonstige",
        en: "Other"
    },
    hairColor_RED: {
        de: "Rot",
        en: "Red"
    },
    hairLength: {
        de: "Haarlänge",
        en: "Hair Length"
    },
    hairLength_AVERAGE: {
        de: "Normal",
        en: "Average"
    },
    hairLength_LONG: {
        de: "Lang",
        en: "Long"
    },
    hairLength_PUNK: {
        en: "Punk"
    },
    hairLength_SHAVED: {
        de: "Rasiert",
        en: "Shaved"
    },
    hairLength_SHORT: {
        de: "Kurz",
        en: "Short"
    },
    height: {
        de: "Größe",
        en: "Height"
    },
    hiddenUsers: {
        de: "Ausgeblendete Benutzer",
        en: "Hidden users"
    },
    hideActivities: {
        de: "In Aktivitäten entfernen",
        en: "Remove in activities"
    },
    hideActivitiesDesc: {
        de: "Versteckt ausgeblendete Benutzer auch in Aktivitäten.",
        en: "Removes hidden users even in the activities."
    },
    hideContacts: {
        de: "In Kontakten entfernen",
        en: "Remove in contacts"
    },
    hideContactsDesc: {
        de: "Versteckt ausgeblendete Benutzer auch in Kontakten.",
        en: "Removes hidden users even in contacts."
    },
    hideLikes: {
        de: "In Bilder-Likes entfernen",
        en: "Remove in picture likes"
    },
    hideLikesDesc: {
        de: "Versteckt ausgeblendete Benutzer auch in Likes von Bildern.",
        en: "Removes hidden users even in likes on pictures."
    },
    hideMessages: {
        de: "In Chat entfernen",
        en: "Remove in chat"
    },
    hideMessagesDesc: {
        de: "Versteckt ausgeblendete Benutzer auch in der Chatliste.",
        en: "Removes hidden users even in the chat list."
    },
    hideUser: {
        de: "Benutzer ausblenden",
        en: "Hide user"
    },
    hideVisits: {
        de: "In Besuchern entfernen",
        en: "Remove in visitors"
    },
    hideVisitsDesc: {
        de: "Versteckt ausgeblendete Benutzer auch in der Besucherliste.",
        en: "Removes hidden users even in the visitor list."
    },
    interests: {
        de: "Interessen",
        en: "Interests"
    },
    interests_ART: {
        de: "Kunst",
        en: "Art"
    },
    interests_BOARDGAME: {
        de: "Brettspiele",
        en: "Board games"
    },
    interests_CAR: {
        de: "Autos",
        en: "Cars"
    },
    interests_COLLECT: {
        de: "Sammeln",
        en: "Collecting"
    },
    interests_COMPUTER: {
        de: "Computer",
        en: "Computers"
    },
    interests_COOK: {
        de: "Kochen",
        en: "Cooking"
    },
    interests_DANCE: {
        en: "Dance"
    },
    interests_FILM: {
        en: "Film & Video"
    },
    interests_FOTO: {
        de: "Fotografie",
        en: "Photography"
    },
    interests_GAME: {
        de: "Computerspiele",
        en: "Gaming"
    },
    interests_LITERATURE: {
        de: "Literatur",
        en: "Literature"
    },
    interests_MODELING: {
        de: "Modellbau",
        en: "Model building"
    },
    interests_MOTORBIKE: {
        de: "Motorrad",
        en: "Motorbikes"
    },
    interests_MUSIC: {
        de: "Musik",
        en: "Music"
    },
    interests_NATURE: {
        de: "Natur",
        en: "Nature"
    },
    interests_POLITICS: {
        de: "Politik",
        en: "Politics"
    },
    interests_TV: {
        en: "TV"
    },
    languages: {
        de: "Sprachen",
        en: "Languages"
    },
    languages_af: {
        de: "Afrikaans",
        en: "Afrikaans"
    },
    languages_ar: {
        de: "Arabisch",
        en: "Arabic"
    },
    languages_arm: {
        de: "Armenisch",
        en: "Armenian"
    },
    languages_az: {
        de: "Aserbaidschanisch",
        en: "Azerbaijani"
    },
    languages_be: {
        de: "Belarussisch",
        en: "Belarusian"
    },
    languages_bg: {
        de: "Bulgarisch",
        en: "Bulgarian"
    },
    languages_bn: {
        de: "Bengali",
        en: "Bengali"
    },
    languages_bs: {
        de: "Bosnisch",
        en: "Bosnian"
    },
    languages_bur: {
        de: "Burmesisch",
        en: "Burmese"
    },
    languages_ca: {
        de: "Katalanisch",
        en: "Catalan"
    },
    languages_ceb: {
        de: "Cebuano",
        en: "Cebuano"
    },
    languages_cs: {
        de: "Tschechisch",
        en: "Czech"
    },
    languages_da: {
        de: "Dänisch",
        en: "Danish"
    },
    languages_de: {
        de: "Deutsch",
        en: "German"
    },
    languages_el: {
        de: "Griechisch",
        en: "Greek"
    },
    languages_en: {
        de: "Englisch",
        en: "English"
    },
    languages_eo: {
        de: "Esperanto",
        en: "Esperanto"
    },
    languages_es: {
        de: "Spanisch",
        en: "Spanish"
    },
    languages_et: {
        de: "Estnisch",
        en: "Estonian"
    },
    languages_eu: {
        de: "Baskisch",
        en: "Basque"
    },
    languages_fa: {
        de: "Persisch",
        en: "Persian"
    },
    languages_fi: {
        de: "Finnisch",
        en: "Finnish"
    },
    languages_fr: {
        de: "Französisch",
        en: "French"
    },
    languages_frc: {
        de: "Kanadisches Französisch",
        en: "Canadian French"
    },
    languages_gd: {
        de: "Schottisch-Gälisch",
        en: "Scottish Gaelic"
    },
    languages_gl: {
        de: "Galician",
        en: "Galician"
    },
    languages_gsw: {
        de: "Schwyzerdütsch",
        en: "Swiss-German"
    },
    languages_hi: {
        de: "Hindi",
        en: "Hindi"
    },
    languages_hr: {
        de: "Kroatisch",
        en: "Croatian"
    },
    languages_hu: {
        de: "Ungarisch",
        en: "Hungarian"
    },
    languages_id: {
        de: "Indonesisch",
        en: "Indonesian"
    },
    languages_is: {
        de: "Isländisch",
        en: "Icelandic"
    },
    languages_it: {
        de: "Italienisch",
        en: "Italian"
    },
    languages_iw: {
        de: "Hebräisch",
        en: "Hebrew"
    },
    languages_ja: {
        de: "Japanisch",
        en: "Japanese"
    },
    languages_ka: {
        de: "Georgisch",
        en: "Georgian"
    },
    languages_kl: {
        de: "Grönländisch",
        en: "Greenlandic (Kalaallisut)"
    },
    languages_km: {
        de: "Kambodschanisch",
        en: "Cambodian"
    },
    languages_kn: {
        de: "Kannada",
        en: "Kannada"
    },
    languages_ko: {
        de: "Koreanisch",
        en: "Korean"
    },
    languages_ku: {
        de: "Kurdisch",
        en: "Kurdish"
    },
    languages_la: {
        de: "Latein",
        en: "Latin"
    },
    languages_lb: {
        de: "Luxemburgisch",
        en: "Luxembourgish"
    },
    languages_lo: {
        de: "Laotisch",
        en: "Lao"
    },
    languages_lt: {
        de: "Litauisch",
        en: "Lithuanian"
    },
    languages_lv: {
        de: "Lettisch",
        en: "Latvian"
    },
    languages_mk: {
        de: "Mazedonisch",
        en: "Macedonian"
    },
    languages_ml: {
        de: "Malayalam",
        en: "Malayalam"
    },
    languages_mr: {
        de: "Marathi",
        en: "Marathi"
    },
    languages_ms: {
        de: "Malaiisch",
        en: "Malay"
    },
    languages_mt: {
        de: "Maltesisch",
        en: "Maltese"
    },
    languages_nl: {
        de: "Niederländisch",
        en: "Dutch"
    },
    languages_no: {
        de: "Norwegisch",
        en: "Norwegian"
    },
    languages_oc: {
        de: "Okzitanisch",
        en: "Occitan"
    },
    languages_pl: {
        de: "Polnisch",
        en: "Polish"
    },
    languages_ps: {
        de: "Paschtunisch",
        en: "Pashto"
    },
    languages_pt: {
        de: "Portugiesisch",
        en: "Portuguese"
    },
    languages_ro: {
        de: "Rumänisch",
        en: "Romanian"
    },
    languages_roh: {
        de: "Rätoromanisch",
        en: "Romansch"
    },
    languages_ru: {
        de: "Russisch",
        en: "Russian"
    },
    languages_sgn: {
        de: "Gebärdensprache",
        en: "Sign language"
    },
    languages_sh: {
        de: "Serbo-Croatian",
        en: "Serbo-Croatian"
    },
    languages_sk: {
        de: "Slowakisch",
        en: "Slovak"
    },
    languages_sl: {
        de: "Slowenisch",
        en: "Slovenian"
    },
    languages_sq: {
        de: "Albanisch",
        en: "Albanian"
    },
    languages_sr: {
        de: "Serbisch",
        en: "Serbian"
    },
    languages_sv: {
        de: "Schwedisch",
        en: "Swedish"
    },
    languages_ta: {
        de: "Tamil",
        en: "Tamil"
    },
    languages_te: {
        de: "Telugu",
        en: "Telugu"
    },
    languages_th: {
        de: "Thailändisch",
        en: "Thai"
    },
    languages_tl: {
        de: "Tagalog",
        en: "Tagalog"
    },
    languages_tr: {
        de: "Türkisch",
        en: "Turkish"
    },
    languages_uk: {
        de: "Ukrainisch",
        en: "Ukrainian"
    },
    languages_us: {
        de: "US-Englisch",
        en: "US English"
    },
    languages_vi: {
        de: "Vietnamesisch",
        en: "Vietnamese"
    },
    languages_wel: {
        de: "Walisisch",
        en: "Welsh"
    },
    languages_wen: {
        de: "Sorbisch",
        en: "Sorbian"
    },
    languages_zgh: {
        de: "Tamazight",
        en: "Tamazight"
    },
    languages_zh: {
        de: "Chinesisch",
        en: "Chinese"
    },
    lastLogin: {
        de: "Letzter Login",
        en: "Last Login"
    },
    location: {
        de: "Ort",
        en: "Location"
    },
    latLong: {
        de: "Breitengrad, Längengrad",
        en: "Latitude, Longitude"
    },
    lookingFor: {
        de: "Ich suche",
        en: "Looking For"
    },
    lookingForOther: {
        de: "Sucht nach",
        en: "They're Looking For"
    },
    maxAge: {
        de: "Maximales Alter",
        en: "Maximal age"
    },
    messages: {
        de: "Nachrichten",
        en: "Messages"
    },
    metadata: {
        de: "Metadaten",
        en: "Metadata"
    },
    minAge: {
        de: "Minimales Alter",
        en: "Minimal age"
    },
    myAge: {
        de: "Mein Alter",
        en: "My Age"
    },
    myGender: {
        de: "Mein Geschlecht",
        en: "My gender"
    },
    myOrientation: {
        de: "Meine Orientierung",
        en: "My orientation"
    },
    new: {
        de: "Neu",
        en: "New"
    },
    noEntry: {
        de: "Keine Angabe",
        en: "No entry"
    },
    onlineStatus: {
        en: "Status"
    },
    onlineStatus_DATE: {
        en: "Date",
    },
    onlineStatus_OFFLINE: {
        en: "Offline"
    },
    onlineStatus_ONLINE: {
        en: "Online"
    },
    onlineStatus_SEX: {
        en: "Now"
    },
    travelersOnly: {
        de: "Nur Reisende",
        en: "Travelers only"
    },
    openTo: {
        de: "Offen für",
        en: "Open to"
    },
    openTo_FRIENDSHIP: {
        de: "Freunde",
        en: "Friends"
    },
    openTo_RELATIONSHIP: {
        de: "Beziehung",
        en: "Relationship"
    },
    openTo_SEXDATES: {
        en: "Sex"
    },
    orientation: {
        de: "Orientierung",
        en: "Orientation"
    },
    orientation_BISEXUAL: {
        de: "Bisexuell",
        en: "Bisexual"
    },
    orientation_GAY: {
        en: "Gay"
    },
    orientation_QUEER: {
        en: "Queer"
    },
    orientation_OTHER: {
        de: "Andere",
        en: "Other"
    },
    orientation_STRAIGHT: {
        de: "Hetero",
        en: "Straight"
    },
    other: {
        de: "Sonstige",
        en: "Other"
    },
    piercings: {
        en: "Piercings"
    },
    piercings_A_FEW: {
        de: "Wenige",
        en: "A few"
    },
    piercings_A_LOT: {
        de: "Viele",
        en: "A lot"
    },
    piercings_NO: {
        de: "Keine Piercings",
        en: "No piercings"
    },
    profileId: {
        de: "Profil-ID",
        en: "Profile ID"
    },
    relationship: {
        de: "Beziehung",
        en: "Relationship"
    },
    relationship_MARRIED: {
        de: "Verheiratet",
        en: "Married"
    },
    relationship_OPEN: {
        de: "Offene Partnerschaft",
        en: "Open"
    },
    relationship_PARTNER: {
        de: "Verpartnert",
        en: "Partner"
    },
    relationship_SINGLE: {
        en: "Single"
    },
    saferSex: {
        de: "Safer Sex",
        en: "Safer sex"
    },
    saferSex_ALWAYS: {
        en: "Safe"
    },
    saferSex_CONDOM: {
        de: "Kondom",
        en: "Condom"
    },
    saferSex_NEEDS_DISCUSSION: {
        de: "Nach Absprache",
        en: "Let's talk"
    },
    saferSex_PREP: {
        en: "PrEP"
    },
    saferSex_PREP_AND_CONDOM: {
        de: "PrEP und Kondom",
        en: "PrEP and condom"
    },
    saferSex_TASP: {
        en: "TasP"
    },
    sendEnter: {
        de: "Enter sendet Nachricht",
        en: "Enter sends message"
    },
    sendEnterDesc: {
        de: "Wenn deaktiviert erzeugt Enter einen Absatz und Strg+Enter versendet die Nachricht.",
        en: "If disabled, Enter creates a new line instead, and Ctrl+Enter sends the message."
    },
    sexual: {
        de: "Sexuelles",
        en: "Sexual"
    },
    sm: {
        de: "SM",
        en: "S&M"
    },
    sm_NO: {
        de: "Kein SM",
        en: "No SM"
    },
    sm_SOFT: {
        en: "Soft SM",
    },
    sm_YES: {
        en: "SM"
    },
    smoker: {
        de: "Raucher",
        en: "Smoker"
    },
    smoker_NO: {
        de: "Nein",
        en: "No"
    },
    smoker_SOCIALLY: {
        de: "Selten",
        en: "Socially"
    },
    smoker_YES: {
        de: "Ja",
        en: "Yes"
    },
    speakingMyLanguage: {
        de: "Spricht meine Sprache",
        en: "Speaking my language"
    },
    systemMessages: {
        de: "Systemnachrichten",
        en: "System messages"
    },
    systemMessagesDesc: {
        de: "Wenn deaktiviert werden Popups wie Standort- oder Fehlermeldungen versteckt.",
        en: "If disabled, popups like GPS or error messages are hidden."
    },
    tattoos: {
        en: "Tattoos"
    },
    tattoos_A_FEW: {
        de: "Wenige",
        en: "A few"
    },
    tattoos_A_LOT: {
        de: "Viele",
        en: "A lot"
    },
    tattoos_NO: {
        de: "Keine Tattoos",
        en: "No tattoos"
    },
    tiles: {
        de: "Benutzerkacheln",
        en: "User Tiles"
    },
    tileCount: {
        de: "Kacheln pro Zeile (0 für Standard)",
        en: "Tiles per row (0 for default)"
    },
    typingNotifications: {
        de: "Tippbenachrichtigungen",
        en: "Typing notifications"
    },
    typingNotificationsDesc: {
        de: "Wenn deaktiviert können Empfänger die Eingabe einer Nachricht nicht mehr sehen.",
        en: "If disabled, receivers can no longer see that a message is being composed."
    },
    viewFullImage: {
        de: "Bild anzeigen",
        en: "View full image"
    },
    viewProfile: {
        de: "Profilvorschau anzeigen",
        en: "Show profile preview"
    },
    weight: {
        de: "Gewicht",
        en: "Weight"
    }
};

function translate(key) {
    const lang = document.documentElement.getAttribute("lang") || "en";
    const translations = _strings[key];
    return translations
        ? translations[lang] || translations.en || "%" + key + "%"
        : "%" + key + "%";
}

function translateEnum(name, key) {
    return translate(`${name}_${key}`);
}

// ---- Settings ----

const settingsNs = "RA_SETTINGS:";
let measurementSystem = "METRIC";
let radarFilter = {};
let tileDetails = new Set();
let tileStyle = null;

function load(name, fallback) {
    const value = localStorage.getItem(settingsNs + name);
    return value === "false" ? false
        : value ? value
            : fallback;
}
function save(name, value) {
    localStorage.setItem(settingsNs + name, value);
}

function getEnhancedFilter() {
    return load("enhancedFilter", true);
}
function getEnhancedTiles() {
    return load("enhancedTiles", true);
}
function getFullHeadlines() {
    return load("fullHeadlines", true);
}
function getFullMessages() {
    return load("fullMessages", true);
}
function getHiddenMaxAge() {
    return load("hiddenMaxAge", 99);
}
function getHiddenMinAge() {
    return load("hiddenMinAge", 18);
}
function getHiddenUsers() {
    return new Set(JSON.parse(load("hiddenUsers", `[]`)));
}
function getHideActivities() {
    return load("hideActivities", true);
}
function getHideContacts() {
    return load("hideContacts", false);
}
function getHideLikes() {
    return load("hideLikes", true);
}
function getHideMessages() {
    return load("hideMessages", false);
}
function getHideVisits() {
    return load("hideVisits", true);
}
function getRadarFilter() {
    return JSON.parse(load("radarFilter", `{}`));
}
function getSavedRadarFilter(id) {
    return getSavedRadarFilters()[id] ?? getRadarFilter();
}
function getSavedRadarFilters() {
    return JSON.parse(load("savedRadarFilters", "{}"));
}
function getSendEnter() {
    return load("sendEnter", true);
}
function getSystemMessages() {
    return load("systemMessages", true);
}
function getTileCount() {
    return parseInt(load("tileCount", 0));
}
function getTileDetails() {
    return new Set(JSON.parse(load("tileDetails", `[ "age", "height", "bodyHair", "bodyType", "relationship", "analPosition" ]`)));
}
function getTypingNotifications() {
    return load("typingNotifications", true);
}
function setEnhancedFilter(value) {
    save("enhancedFilter", value);
}
function setEnhancedTiles(value) {
    save("enhancedTiles", value);
}
function setFullHeadlines(value) {
    setCssProp("--tile-headline-white-space", value ? "unset" : "nowrap");
    save("fullHeadlines", value);
}
function setFullMessages(value) {
    setCssProp("--message-line-clamp", value ? "unset" : "2");
    save("fullMessages", value);
}
function setHiddenMaxAge(value) {
    save("hiddenMaxAge", value);
}
function setHiddenMinAge(value) {
    save("hiddenMinAge", value);
}
function setHideActivities(value) {
    save("hideActivities", value);
}
function setHideContacts(value) {
    save("hideContacts", value);
}
function setHideLikes(value) {
    save("hideLikes", value);
}
function setHideMessages(value) {
    save("hideMessages", value);
}
function setHideVisits(value) {
    save("hideVisits", value);
}
function setRadarFilter() {
    save("radarFilter", JSON.stringify(radarFilter));
}
function setSavedRadarFilter(id, value = null) {
    const filters = JSON.parse(load("savedRadarFilters", "{}"));
    if (value)
        filters[id] = value;
    else
        delete filters[id];
    save("savedRadarFilters", JSON.stringify(filters));
}
function setSendEnter(value) {
    save("sendEnter", value);
}
function setSystemMessages(value) {
    setCssProp("--system-message-visibility", value ? "visible" : "collapse");
    save("systemMessages", value);
}
function setTileCount(value) {
    if (value) {
        setCssProp("--tile-count", value);
        if (!tileStyle) {
            tileStyle = addCss(`
            :root {
                --tile-count: 0;
                --tile-size: calc(100% / max(1, var(--tile-count)));
            }
            /* desktop - do not adjust "Discover" .js-strip tile sizes for now */
            .search-results__item:not(.js-strip .search-results__item) {
                padding-bottom: var(--tile-size) !important;
                width: var(--tile-size) !important;
            }
            /* mobile - starts at 768px at which .search-results__item becomes inline, requiring to adjust .tile */
            @media not screen and (min-width: 768px) {
                .tile:not(.js-strip .tile):not(.tile--small) {
                    width: var(--tile-size) !important;
                }
            }
            `);
        }
    } else {
        if (tileStyle) {
            tileStyle.remove();
            tileStyle = null;
        }
    }
    save("tileCount", value);
}
function setTileDetail(key, visible) {
    if (visible) {
        tileDetails.add(key);
    } else {
        tileDetails.delete(key);
    }
    save("tileDetails", JSON.stringify(Array.from(tileDetails)));
}
function setTypingNotifications(value) {
    save("typingNotifications", value);
}
function setUserHidden(username, hide) {
    let hiddenUsers = getHiddenUsers();
    if (hide) {
        hiddenUsers.add(username);
    } else {
        hiddenUsers.delete(username);
    }
    save("hiddenUsers", JSON.stringify(Array.from(hiddenUsers)));
}

// ---- XHR ----

const xhrHooks = {};
const xhrOpened = {};

function hookXhr() {
    function callHook(func, e) {
        // Log XHR.
        if (func !== "open") {
            const prefix = func === "load" ? e.status : "<<<";
            if (e.body) {
                log(`${prefix} ${e.method} ${e.url}`, e.body);
            } else {
                log(`${prefix} ${e.method} ${e.url}`);
            }
        }

        // Only handle success for now.
        if (e.status && (e.status < 200 || e.status > 299)) {
            return false;
        }

        // Extract route.
        const routeStart = e.url.indexOf("/api/");
        if (routeStart === -1) {
            return false;
        }
        const routeEnd = e.url.indexOf("?");
        const route = e.url.substring(routeStart + "/api/".length, routeEnd === -1 ? undefined : routeEnd);

        // Route to matching hook.
        if (xhrHooks[func] && xhrHooks[func][e.method]) {
            for (const [hookRoute, callback] of Object.entries(xhrHooks[func][e.method])) {
                e.args = matchRoute(route, hookRoute);
                if (e.args !== undefined) {
                    log(`🪝 ${func} ${e.method} ${route}`);
                    callback(e);
                    return true;
                }
            }
        }
        return false;
    }
    function matchRoute(route, match) {
        if (route === match) {
            return [];
        }
        const routeParts = route.split("/");
        const matchParts = match.split("/");
        if (routeParts.length !== matchParts.length) {
            return;
        }
        const args = [];
        for (let i = 0; i < routeParts.length; ++i) {
            if (matchParts[i] === "*") {
                args.push(routeParts[i]);
            } else if (routeParts[i] !== matchParts[i]) {
                return;
            }
        }
        return args;
    }

    // Hook open.
    const realOpen = window.XMLHttpRequest.prototype.open;
    window.XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
        const e = { method: method, url: url };
        xhrOpened[this] = e;

        if (callHook("open", e)) {
            method = e.method;
            url = e.url;
        }
        if (!e.cancel) {
            realOpen.apply(this, arguments);
        }

        // Hook load.
        this.addEventListener("load", () => {
            const json = isJson(this.response);
            e.body = json ? JSON.parse(this.response) : this.response;
            e.status = this.status;

            if (callHook("load", e)) {
                Object.defineProperty(this, "responseText", { writable: true });
                this.responseText = json ? JSON.stringify(e.body) : e.body;
            }
            if (e.cancel) {
                this.response = null;
                this.responseText = null;
                this.status = 404;
            }
        });
    };

    // Hook send.
    const realSend = window.XMLHttpRequest.prototype.send;
    window.XMLHttpRequest.prototype.send = function (body) {
        const e = xhrOpened[this];
        delete xhrOpened[this];
        const json = isJson(body);
        if (body) {
            e.body = json ? JSON.parse(body) : body;
        }

        if (callHook("send", e)) {
            if (e.body) {
                body = json ? JSON.stringify(e.body) : body;
            }
        }
        if (!e.cancel) {
            realSend.apply(this, arguments);
        }
    };
}

function onXhr(func, method, route, callback) {
    if (!xhrHooks[func])
        xhrHooks[func] = {};
    if (!xhrHooks[func][method])
        xhrHooks[func][method] = {};
    xhrHooks[func][method][route] = callback;
}

// ---- Romeo ----

addCss(`
:root {
    --message-line-clamp: 2;
    --system-message-visibility: visible;
    --tile-headline-white-space: nowrap;
}

.feedback {
    visibility: var(--system-message-visibility);
}

/* hide models on login page */
div[data-testid="desktop-image"] {
    background-image: none;
}

/* hide PLUS icon as PLUS is faked to enhance tiles */
.js-romeo-badge {
    display: none;
}

/* hide PLUS message at bottom of visitor grid */
.visitors div[class^="UnlockMoreVisitorsGrid"] {
    display: none;
}

/* prevent mobile photo view on non-mobile displays */
@media screen and (min-width: 768px) {
    .ReactModal__Overlay.ReactModal__Overlay--after-open > .ReactModal__Content.ReactModal__Content--after-open > main {
        background: none;
    }
    .container-button-next, .container-button-prev {
        display: flex;
    }
    .swiper-zoom-container > img {
        height: initial;
    }
}
`);

function getBackgroundImageUrl(style) {
    return style.match(/"(.+)"/)[1];
}

function getFullImageUrl(url) {
    return url.replace(/squarish\/[0-9]*x[0-9]*\//, "");
}

function getUsernameFromHref(href) {
    let start = href.indexOf("profile/");
    if (start === -1) {
        start = href.indexOf("hunq/");
    }
    return href.substring(start).split("/")[1];
}

function xhrHandleSession(reply) {
    const settings = reply.bb_settings;
    if (settings) {
        // Determine measurement locale.
        measurementSystem = settings.interface?.measurement_system ?? measurementSystem;

        // Determine radar filter, remove deleted ones.
        const radarFilterId = settings.bluebird?.search_filter?.id;
        radarFilter = getSavedRadarFilter(radarFilterId);
        for (const savedFilterId of Object.keys(getSavedRadarFilters())) {
            if (savedFilterId && !reply.data.search_filters.find(x => x.id === savedFilterId)) {
                setSavedRadarFilter(savedFilterId);
            }
        }
    }

    // Enable client-side PLUS capabilities.
    const caps = reply.data?.capabilities;
    if (caps) {
        caps.can_save_unlimited_searches = true; // enables filter bookmarks
        caps.can_set_plus_radar_style = true; // enables Grid Stats selection
    }
}

onXhr("load", "GET", "v4/session", e => xhrHandleSession(e.body));

// ---- Context Menu ----

addCss(`
#ra_context_bg {
    background: transparent;
    display: none;
    height: 100%;
    position: fixed;
    z-index: 10000;
    width: 100%;
}

#ra_context_ul {
    background: #121212;
    border: 1px solid #000;
    display: none;
    font-size: 0.8rem;
    position: absolute;
    z-index: 10001;
}

.ra_context_li {
    color: #FFF;
    cursor: default;
    padding: 5px 15px 5px 0px;
    white-space: nowrap;
}

.ra_context_li .icon {
    color: #00BDFF;
    margin: 0px 10px 0px 13px;
}

.ra_context_li:hover, .ra_context_li:active {
    background-color: hsla(0,0%,100%,.125);
}

@media screen and (max-width: 767px) {
    #ra_context_bg {
        background: #0000007F;
    }

    #ra_context_ul {
        bottom: 0;
        left: unset !important;
        font-size: 1.2rem;
        top: unset !important;
        position: fixed;
        width: 100%;
    }

    .ra_context_li {
        padding: 20px;
    }
}
`);

let contextMenuBg;
let contextMenuUl;
let contextMenuX;
let contextMenuY;
const contextMenuHandlers = {
    // radar > contact tile
    ".js-contact": el => {
        const username = getUsernameFromHref(el.href);
        showContextMenu(
            { icon: "search", text: "viewProfile", onclick: () => showProfilePreview(username) },
            {
                icon: "hide-visit", text: "hideUser", onclick: () => {
                    setUserHidden(username, true);
                    if (getHideContacts())
                        el.parentNode.style.display = "none";
                }
            }
        );
    },
    // radar > user tile
    ".search-results__item .tile:not(div[class*='tile--loading--'])": el => {
        const a = el.querySelector("a");
        const username = getUsernameFromHref(a.href);
        showContextMenu(
            { icon: "search", text: "viewProfile", onclick: () => showProfilePreview(username) },
            {
                icon: "hide-visit", text: "hideUser", onclick: () => {
                    setUserHidden(username, true);
                    if (el.classList.contains("tile--plus")) {
                        el.closest(".search-results__item").style.display = "none";
                    } else {
                        el.style.display = "none";
                    }
                }
            }
        );
    },
    // radar > small user tile
    ".search-results__item .tile[class*='tile--loading--']": el => {
        const small = el.querySelector("div.SMALL");
        showContextMenu(
            { icon: "search", text: "viewFullImage", onclick: () => showImagePreview(getFullImageUrl(getBackgroundImageUrl(small.style.backgroundImage))) }
        );
    },
    // search > user tile
    ".js-results .tile:not(div[class*='tile--loading--'])": el => {
        const a = el.querySelector("a");
        const username = getUsernameFromHref(a.href);
        showContextMenu(
            { icon: "search", text: "viewProfile", onclick: () => showProfilePreview(username) },
            {
                icon: "hide-visit", text: "hideUser", onclick: () => {
                    setUserHidden(username, true);
                    el.style.display = "none";
                }
            }
        );
    },

    // visitors > user tile
    ".visitors .tile:not(div[class*='tile--loading--'])": el => {
        const a = el.querySelector("a");
        const username = getUsernameFromHref(a.href);
        showContextMenu(
            { icon: "search", text: "viewProfile", onclick: () => showProfilePreview(username) },
            {
                icon: "hide-visit", text: "hideUser", onclick: () => {
                    setUserHidden(username, true);
                    if (getHideVisits())
                        el.style.display = "none";
                }
            }
        );
    },

    // messages > message > sent image
    ".js-chat .reactView img": el => {
        showContextMenu(
            { icon: "search", text: "viewFullImage", onclick: () => showImagePreview(getFullImageUrl(el.src)) }
        );
    },
    // messages > message
    ".js-chat .reactView": el => {
        const a = el.querySelector("a");
        const username = getUsernameFromHref(a.href);
        showContextMenu(
            { icon: "search", text: "viewProfile", onclick: () => showProfilePreview(username) },
            {
                icon: "hide-visit", text: "hideUser", onclick: () => {
                    setUserHidden(username, true);
                    if (getHideMessages())
                        el.style.display = "none";
                }
            }
        );
    },

    // contacts > contact
    ".js-contacts .reactView": el => {
        const a = el.querySelector("a");
        const username = getUsernameFromHref(a.href);
        showContextMenu(
            { icon: "search", text: "viewProfile", onclick: () => showProfilePreview(username) },
            {
                icon: "hide-visit", text: "hideUser", onclick: () => {
                    setUserHidden(username, true);
                    if (getHideContacts())
                        el.style.display = "none";
                }
            }
        );
    },

    // activities > activity > liked image
    "img.thumbnail": el => {
        showContextMenu(
            { icon: "search", text: "viewFullImage", onclick: () => showImagePreview(getFullImageUrl(el.src)) }
        );
    },
    // activities > activity
    ".js-stream .js-list .listitem": el => {
        const a = el.querySelector("a");
        const username = getUsernameFromHref(a.href);
        showContextMenu(
            { icon: "search", text: "viewProfile", onclick: () => showProfilePreview(username) },
            {
                icon: "hide-visit", text: "hideUser", onclick: () => {
                    setUserHidden(username, true);
                    if (getHideActivities())
                        el.style.display = "none";
                }
            }
        );
    }
};

function hookContextMenu() {
    // Create context menu canceler.
    contextMenuBg = addElement(document.body, "<div id='ra_context_bg'></ul>");
    contextMenuBg.addEventListener("click", e => hideContextMenu());

    // Create context menu.
    contextMenuUl = addElement(document.body, "<ul id='ra_context_ul'></ul>");

    // Attach to events.
    addEventListener("contextmenu", e => {
        contextMenuX = e.clientX;
        contextMenuY = e.clientY;

        // Go through hierarchy of clicked elements.
        for (const el of document.elementsFromPoint(contextMenuX, contextMenuY)) {
            // Stop when hitting a layer.
            if (el.classList.contains("layer")
                || el.classList.contains("layout")
                || el.classList.contains("ReactModal__Overlay")) {
                break;
            }
            // Invoke first context handler for this element.
            for (const [key, value] of Object.entries(contextMenuHandlers)) {
                if (el.matches(key)) {
                    log(`opening menu '${key}'`);
                    value(el);
                    e.preventDefault();
                    return;
                }
            }
        }
    });
}

function showContextMenu() {
    contextMenuBg.style.display = "block";

    contextMenuUl.replaceChildren();
    for (const item of arguments) {
        const li = addElement(contextMenuUl, `<li class="ra_context_li">
            <span class="icon icon-${item.icon}"></span>
            ${translate(item.text)}
        </li>`);
        li.addEventListener("click", e => {
            hideContextMenu();
            item.onclick();
        });
    }

    contextMenuUl.style.display = "block";
    const maxX = window.innerWidth - contextMenuUl.offsetWidth;
    const maxY = window.innerHeight - contextMenuUl.offsetHeight;
    contextMenuUl.style.left = Math.min(contextMenuX, maxX) + "px";
    contextMenuUl.style.top = Math.min(contextMenuY, maxY) + "px";
}

function hideContextMenu() {
    contextMenuBg.style.display = "none";
    contextMenuUl.style.display = "none";
}

// ---- Lists ----

addCss(`
.ra_list {
    display: flex;
    flex-direction: column;
    height: 250px;
}

.ra_list > div > a {
    margin: 0 8px;

    display: inline-flex;
}

.ra_list > ul {
    flex: 1;
    padding: 2px;
    overflow-y: auto;

    display: flex;
    flex-wrap: wrap;
    align-content: flex-start;
}

.ra_list > ul > li {
    flex: 50%;
    flex-grow: 0;
    padding: 2px;
}
`);

function createList(parent, { onGet, onName = null, onAdd = null, onRemove = null, onImport = null, onExport = null } = {}) {
    const container = addElement(parent, `<div class="ra_list"></div>`);

    function createButton(icon, text) {
        return `<a href="#" class="icon-labeled plain-text-link">
                    <span class="icon icon-base ${icon}"></span>
                    <span class="icon-labeled__label">${text}</span>
                </a>`;
    }

    // Add elements.
    const toolbar = addElement(container, `<div></div>`);
    const ul = addElement(container, `<ul></ul>`);

    // Create toolbar.
    const searchBox = addElement(toolbar, `<input class="input" type="text" placeholder="Search"></input>`);
    searchBox.addEventListener("input", e => updateList());

    function updateList() {
        const filter = searchBox.value.toUpperCase();
        ul.replaceChildren();

        const elements = onGet();
        for (const element of elements) {
            // Check if filtered away.
            const name = onName ? onName(element) : element;
            if (!name.toUpperCase().includes(filter)) {
                continue;
            }

            // Create list entry.
            const li = addElement(ul, `<li></li>`);
            if (onRemove) {
                const deleteButton = addElement(li, createButton("icon-cross-negative", name));
                deleteButton.addEventListener("click", e => {
                    e.preventDefault();
                    onRemove(element);
                    li.remove();
                });
            } else {
                addElement(li, `<div>${name}</div>`);
            }
        }
    }

    if (onAdd) {
        const addButton = addElement(toolbar, createButton("icon-add-attachment", "Add"));
        addButton.addEventListener("click", e => {
            e.preventDefault();
            onAdd(searchBox.value);
            updateList();
        });
    }

    if (onRemove) {
        const clearButton = addElement(toolbar, createButton("icon-trashcan", "Clear"));
        clearButton.addEventListener("click", e => {
            e.preventDefault();
            if (confirm(translate("clearList"))) {
                const elements = onGet();
                for (const element of elements) {
                    onRemove(element);
                }
                updateList();
            }
        });
    }

    if (onImport) {
        const importButton = addElement(toolbar, createButton("icon-up-arrow", "Import"));
        importButton.addEventListener("click", e => {
            e.preventDefault();
            // TODO: Handle import
            updateList();
        });
    }

    if (onExport) {
        const exportButton = addElement(toolbar, createButton("icon-down-arrow", "Export"));
        exportButton.addEventListener("click", e => {
            e.preventDefault();
            // TODO: Handle export
            updateList();
        });
    }

    // Create list.
    updateList();
}

// ---- Previews ----

addCss(`
#ra_preview_inner {
    background-color: black;
    display: grid;
    grid-template-rows: min-content auto;
    height: 100%;
}

#ra_image_content {
    overflow-y: scroll;
    padding: 16px;
}

#ra_profile_content {
    display: grid;
    font-family: Inter, Helvetica, Arial, "Open Sans", sans-serif;
    grid-template-columns: auto 352px;
    overflow-y: scroll;
    word-break: break-word;
}

#ra_profile_left {
    background: #121212;
    overflow-y: scroll;
    padding: 16px;
}

#ra_profile_right {
    overflow-y: scroll;
    padding: 16px;
}

.ra_profile_details:not(:first-child) {
    border-top: 1px solid rgb(46, 46, 46);
    margin-top: 1rem;
}

.ra_profile_summary {
    padding: 1rem 0;
}

.ra_profile_keyvalue {
    display: grid;
    gap: 16px;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}

.ra_profile_keyvalue > :first-child {
    color: rgba(255, 255, 255, 0.6);
    text-align: right;
}

#ra_profile_text {
    white-space: pre-line;
}

@media screen and (max-width: 767px) {
    #ra_profile_content {
        grid-template-columns: initial;
        grid-template-rows: auto auto;
    }
    #ra_profile_left {
        overflow-y: initial;
    }
    #ra_profile_right {
        overflow-y: initial;
    }
    #ra_profile_pic {
        width: 100%;
    }
}
`);

let previewLayer;

function createPreview(title) {
    const container = document.querySelector("#spotlight-container");
    previewLayer = addElement(container, `<div class="layer layer--spotlight" style="top:0;z-index:100;">
        <div id="ra_preview_inner">
            <div class="js-header layout-item">
                <div class="layer-header layer-header--primary">
                    <a class="back-button l-tappable js-back marionette" href="#">
                        <span class="js-back-icon icon icon-cross icon-regular"></span>
                    </a>
                    <div class="layer-header__title js-title typo-section-navigation" style="text-align:center">
                        <h2>${title}</h2>
                    </div>
                </div>
            </div>
        </div>
    </div>`);

    previewLayer.addEventListener("click", e => {
        if (e.target === previewLayer) {
            previewLayer.remove();
        }
    });
    previewLayer.querySelector(".js-back").addEventListener("click", e => previewLayer.remove());

    return previewLayer.querySelector("#ra_preview_inner");
}

function hookPreviewHistory() {
    window.addEventListener("popstate", e => {
        // Restore navigating back to preview.
        switch (e.state?.ra_preview) {
            case "image":
                showImagePreview(e.state.src, false);
                break;
            case "profile":
                showProfilePreview(e.state.username, false);
                break;
        }
    });
    window.navigation?.addEventListener("navigate", e => {
        // Hide preview on any other navigation.
        previewLayer?.remove();
    });
}

function showImagePreview(src, pushHistory = true) {
    if (pushHistory) {
        history.pushState({ ra_preview: "image", src: src }, "");
    }

    const content = addElement(createPreview(translate("viewFullImage")), `<div id="ra_image_content"></div>`);
    addElement(content, `<img id="ra_profile_pic" src="${src}"></img>`);
}

function showProfilePreview(username, pushHistory = true) {
    const profile = profileCache[username];
    if (!profile) {
        return;
    }

    if (pushHistory) {
        history.pushState({ ra_preview: "profile", username: username }, "");
    }

    function isEntry(value) {
        return value && value !== "NO_ENTRY";
    }

    function addSection(el, key) {
        return addElement(el, `<details class="ra_profile_details" open>
            <summary class="ra_profile_summary">${translate(key)}</summary>
        </details>`);
    }

    function add(section, key, value) {
        if (value) {
            addElement(section, `<div class="ra_profile_keyvalue">
                <div>${translate(key)}</div>
                <div>${value}</div>
            </div>`);
        }
    }
    function addAgeRange(section, range) {
        if (range) {
            add(section, "ageRange", getProfileAgeRange(range));
        }
    }
    function addArrayEnum(section, key, array) {
        if (!array) {
            return;
        }
        let values = [];
        for (let i = 0; i < array.length; i++) {
            if (isEntry(array[i])) {
                values.push(translateEnum(key, array[i]));
            }
        }
        if (values.length) {
            add(section, key, values.join(", "));
        }
    }
    function addDistance(section, distance, sensor) {
        let text = measurementSystem === "METRIC"
            ? `${distance / 1000} km`
            : `${round(distance * M2MI, 1)}mi`;
        if (sensor) {
            text += " (GPS)";
        }
        add(section, "distance", text);
    }
    function addEnum(section, key, value) {
        if (isEntry(value)) {
            add(section, key, translateEnum(key, value));
        }
    }
    function addGender(section, genderOrientation) {
        let values = [];
        if (isEntry(genderOrientation?.orientation)) {
            values.push(translateEnum("orientation", genderOrientation.orientation));
        }
        if (isEntry(genderOrientation?.gender)) {
            values.push(translateEnum("gender", genderOrientation.gender));
        }
        if (values.length) {
            add(section, "genderOrientation", values.join(" / "));
        }
    }

    const content = addElement(createPreview(username), `<div id="ra_profile_content"></div>`);
    const left = addElement(content, `<div id="ra_profile_left"></div>`);
    const right = addElement(content, `<div id="ra_profile_right"></div>`);

    addElement(left, `<div>${escapeHtml(profile.headline ?? "")}</div>`);

    const img = addElement(left, `<img id="ra_profile_pic"></img>`);
    img.src = profile.pic ? `/img/usr/${profile.pic}.jpg` : "/assets/f8a7712027544ed03920.svg";

    const section = addSection(right, "metadata");
    addEnum(section, "onlineStatus", profile.online_status);
    add(section, "lastLogin", formatTime(profile.last_login));
    add(section, "location", `${profile.location.name}, ${profile.location.country}`);
    addDistance(section, profile.location.distance, profile.location.sensor);
    add(section, "profileId", profile.id);

    const personal = profile.personal;
    if (personal) {
        const section = addSection(right, "general");
        add(section, "age", personal.age);
        add(section, "height", getProfileHeight(personal.height));
        add(section, "weight", getProfileWeight(personal.weight));
        add(section, "bmi", getProfileBmi(personal.height, personal.weight, true));
        add(section, "bodyType", getProfileEnum("bodyType", personal.body_type));
        add(section, "ethnicity", getProfileEnum("ethnicity", personal.ethnicity));
        addEnum(section, "hairLength", personal.hair_length);
        addEnum(section, "hairColor", personal.hair_color);
        addEnum(section, "beard", personal.beard);
        addEnum(section, "eyeColor", personal.eye_color);
        add(section, "bodyHair", getProfileEnum("bodyHair", personal.body_hair));
        addGender(section, personal?.gender_orientation);
        addEnum(section, "smoker", personal.smoker);
        addEnum(section, "tattoos", personal.tattoo);
        addEnum(section, "piercings", personal.piercing);
        addArrayEnum(section, "languages", personal.spoken_languages);
        add(section, "relationship", getProfileEnum("relationship", personal.relationship));
    }

    const sexual = profile.sexual;
    if (sexual) {
        const section = addSection(right, "sexual");
        add(section, "analPosition", getProfileEnum("analPosition", sexual.anal_position));
        add(section, "dick", getProfileDick(sexual.dick_size, sexual.concision));
        addArrayEnum(section, "fetish", sexual.fetish);
        add(section, "dirty", getProfileEnum("dirty", sexual.dirty_sex));
        addEnum(section, "fisting", sexual.fisting);
        addEnum(section, "sm", sexual.sm);
        add(section, "saferSex", getProfileEnum("saferSex", sexual.safer_sex));
        if (!section.querySelectorAll(".ra_profile_keyvalue").length) {
            section.remove();
        }
    }

    if (personal) {
        const section = addSection(right, "lookingFor");
        addArrayEnum(section, "openTo", personal.looking_for);
        addAgeRange(section, personal.target_age);
        addArrayEnum(section, "gender", personal.gender_orientation?.looking_for_gender);
        addArrayEnum(section, "orientation", personal.gender_orientation?.looking_for_orientation);
        if (!section.querySelectorAll(".ra_profile_keyvalue").length) {
            section.remove();
        }
    }

    if (profile.personal?.profile_text) {
        const section = addSection(right, "aboutMe");
        addElement(section, `<div id="ra_profile_text">${profile.personal.profile_text}</div>`);
    }
}

// ---- Profiles ----

let profileCache = {};

function cacheProfile(user) {
    const existing = profileCache[user.name];
    const profile = {
        id: user.id,
        name: user.name,
        headline: user.headline ?? existing?.headline,
        last_login: user.last_login ?? existing?.last_login,
        location: user.location ?? existing?.location,
        online_status: user.online_status ?? existing?.online_status,
        pic: user.preview_pic?.url_token ?? existing?.pic, // not available if no picture
        personal: user.profile?.personal ?? user.personal ?? existing?.personal, // not available in activities
        sexual: user.profile?.sexual ?? user.sexual ?? existing?.sexual, // not available in activities
        albums: user.albumsV2 ?? existing?.albumsV2
    };
    profileCache[profile.name] = profile;
    return profile;
}

function filterProfile(profile, hiddenMaxAge, hiddenMinAge, hiddenNames) {
    // Return whether to display the profile.
    return (!profile.personal || profile.personal.age >= hiddenMinAge && profile.personal.age <= hiddenMaxAge)
        && !hiddenNames.has(profile.name);
}

function getProfileAgeRange(range, short) {
    if (range) {
        const min = range.min ?? "18";
        const max = range.max ?? "99";
        return short
            ? `${min}-${max}`
            : translate("ageRangeValue").replace("$from", min).replace("$to", max);
    }
}
function getProfileBmi(height, weight, withName) {
    if (height && weight) {
        const bmi = weight / Math.pow(height / 100, 2);
        let result = `${round(bmi, 1).toFixed(1)}`;
        if (withName) {
            const name
                = bmi < 16 ? translate("bmiSevereThin")
                    : bmi < 17 ? translate("bmiModerateThin")
                        : bmi < 18.5 ? translate("bmiMildThin")
                            : bmi < 25 ? translate("bmiNormal")
                                : bmi < 30 ? translate("bmiPreObese")
                                    : bmi < 35 ? translate("bmiObese1")
                                        : bmi < 40 ? translate("bmiObese2")
                                            : translate("bmiObese3");
            result += ` / ${name}`;
        }
        return result;
    }
}
function getProfileDick(size, concision) {
    let values = [];
    if (size && size !== "NO_ENTRY") {
        values.push(translateEnum("dick", size));
    }
    if (concision && concision !== "NO_ENTRY") {
        values.push(translateEnum("concision", concision));
    }
    if (values.length) {
        return values.join(" - ");
    }
}
function getProfileEnum(key, value) {
    if (value && value !== "NO_ENTRY") {
        return translateEnum(key, value);
    }
}

function getProfileHeight(height) {
    if (height) {
        return measurementSystem === "METRIC"
            ? `${height}cm`
            : `${round(height * CM2FT, 2)} ft`;
    }
}
function getProfileWeight(weight) {
    if (weight) {
        return measurementSystem === "METRIC"
            ? `${weight}kg`
            : `${round(weight * KG2LBS)}lbs`;
    }
}

function xhrHandleProfiles(e) {
    e.body.items = xhrHandleUsers(e.body.items, x => x, true);
    e.body = xhrHandleTiles(e.body);
}
function xhrHandleTiles(reply) {
    // Restore PLUS-visible visitors.
    reply.items_limited = reply.items_total;

    // Show as "large tiles" to display user details everywhere.
    if (getEnhancedTiles()) {
        for (let item of reply.items ?? []) {
            if (item.display) {
                item.display.large_tile = true;
            }
        }
    }

    return reply;
}
function xhrHandleUsers(items, userSelector, filter) {
    let newItems = [];
    const hiddenMaxAge = getHiddenMaxAge();
    const hiddenMinAge = getHiddenMinAge();
    const hiddenNames = getHiddenUsers();

    for (const item of items ?? []) {
        const profile = cacheProfile(userSelector(item));
        if (!filter || filterProfile(profile, hiddenMaxAge, hiddenMinAge, hiddenNames)) {
            newItems.push(item);
        }
    }

    return newItems;
}
function xhrHandleVisits(e) {
    e.body.items = xhrHandleUsers(e.body.items, x => x, getHideVisits());
    e.body = xhrHandleTiles(e.body);
}

onXhr("load", "GET", "v4/contacts", e => {
    if (e.body.cursors) {
        e.body.items = xhrHandleUsers(e.body.items, x => x.profile, getHideContacts());
    }
});

onXhr("load", "GET", "v4/messages/conversations", e => {
    e.body.items = xhrHandleUsers(e.body.items, x => x.chat_partner, getHideMessages());
});

onXhr("load", "GET", "+/notifications/activity-stream", e => {
    e.body = xhrHandleUsers(e.body, x => x.partner, getHideActivities());
});

onXhr("load", "GET", "v4/hunqz/profiles", e => xhrHandleProfiles(e));
onXhr("load", "GET", "v4/profiles", e => xhrHandleProfiles(e));
onXhr("load", "GET", "v4/profiles/list", e => xhrHandleProfiles(e));
onXhr("load", "GET", "v4/profiles/popular", e => xhrHandleProfiles(e));

onXhr("load", "GET", "v4/visitors", e => xhrHandleVisits(e));
onXhr("load", "GET", "v4/visits", e => xhrHandleVisits(e));

onXhr("load", "GET", "v4/messages/*", e => cacheProfile(e.body));
onXhr("load", "GET", "v4/profiles/*", e => cacheProfile(e.body));
onXhr("load", "GET", "v4/profiles/*/full", e => cacheProfile(e.body));

onXhr("load", "GET", "v4/reactions/pictures/basic", e => {
    e.body.items = xhrHandleUsers(e.body.items, x => x.user_id, getHideLikes());
});

// ---- Filter ----

addCss(`
/* enhanced radar filter */
.js-quick-filter {
    overflow-y: scroll;
}

/* restore bookmark icon color */
.ui-navbar__button--bookmarks .icon.icon-bookmark-outlined {
    color: #00bdff !important;
}

/* fix height of filter on mobile */
.sidebar .filter-container {
    height: unset !important;
}
`);

onElement(`.js-quick-filter`, el => {
    replaceFilterContainer(el.parentNode);
});

function addRadarFilter(filter, key, value) {
    if (!isMultiRadarFilter(key)) {
        filter[key] = value;
    } else if (key in filter) {
        filter[key].push(value);
    } else {
        filter[key] = [value];
    }
}

function hasRadarFilter(filter, key, value) {
    return key in filter
        && (value === undefined || (isMultiRadarFilter(key)
            ? filter[key].includes(value)
            : filter[key] === value));
}

function isMultiRadarFilter(key) {
    return key.endsWith("[]");
}

function removeRadarFilter(filter, key, value) {
    if (!hasRadarFilter(filter, key, value)) {
        return;
    }
    if (isMultiRadarFilter(key)) {
        filter[key] = filter[key].filter(x => x !== value);
        if (!filter[key].length) {
            delete filter[key];
        }
    } else {
        delete filter[key];
    }
}

function refreshFilter() {
    // Save filter.
    setRadarFilter();
    // Reset filter title, enable filter reset button.
    document.querySelector(`.js-filter-header p[class^="ResponsiveBodyText-sc-"]`).innerHTML = translate("filters");
    document.querySelector(".js-clear-all").classList.remove("is-disabled");
    // Update results.
    document.querySelector("section.js-main-stage div.js-navigation a.is-selected, div.js-nav-item").click();
}

function packRadarFilter(keyValues) {
    let filter = {};
    for (const [key, value] of keyValues) {
        addRadarFilter(filter, key, value);
    }
    return filter;
}

function unpackRadarFilter(filter) {
    let keyValues = [];
    for (const key in filter) {
        if (isMultiRadarFilter(key)) {
            for (const value of filter[key]) {
                keyValues.push([key, value]);
            }
        } else {
            keyValues.push([key, filter[key]]);
        }
    }
    return keyValues;
}

function replaceFilterContainer(el) {
    if (!getEnhancedFilter()) {
        return;
    }

    // Remove plus color from bookmark action.
    const save = el.querySelector(".js-filter-actions .js-save");
    save?.classList.remove("is-plus");

    // Remove all filters on reset.
    const clearAll = el.querySelector(".js-filter-actions .js-clear-all");
    if (Object.keys(radarFilter).length) {
        clearAll.classList.remove("is-disabled");
    }
    clearAll.addEventListener("click", e => {
        radarFilter = {};
        setRadarFilter();
        // Filter panel is recreated by default handler, recreate selections.
        setTimeout(() => replaceFilterContainer(el));
    });

    // Clear any remaining extended filters.
    filter = el.querySelector(".filter");
    for (const tags of filter.querySelectorAll(".filter__params-tags.js-tags-list")) {
        tags.remove();
    }

    // Remove PLUS-Filter ad if no original filters are selected.
    if (filter.querySelector(".js-quick-filter .js-add-params-button.plain-text-link")) {
        filter.querySelector(".js-quick-filter .filter__group-more-options").remove();
    }

    // Add custom filters.

    function addSection(text) {
        return addElement(filter, `<div class="filter__params-tags js-tags-list">
        <h3 class="typo mb-">${translate(text)}</h3>
        </div>`);
    }

    function addSectionList(text) {
        const section = addSection(text);
        return addElement(section, `<ul class="js-list tags-list"></ul>`);
    }

    function addSectionListMulti(text, prefix, filterKey, filterValues, hasNoEntry = true) {
        const section = addSectionList(text);
        for (const filterValue of filterValues) {
            addListTagFilter(section, `${prefix}_${filterValue}`, filterKey, filterValue);
        }
        if (hasNoEntry) {
            addListTagFilter(section, "noEntry", filterKey, "NO_ENTRY");
        }
        return section;
    };

    function addListTag(ul, text, selected, change) {
        const li = addElement(ul, `
        <li class="tags-list__item">
            <a class="js-tag ui-tag ui-tag--removable txt-truncate">
                <span class="ui-tag__label">${translate(text)}</span>
            </a>
        </li>`);
        const a = li.querySelector("a");
        if (selected) {
            a.classList.add("ui-tag--selected");
        }
        li.addEventListener("click", e => {
            e.preventDefault();
            if (a.classList.contains("ui-tag--selected")) {
                change(false);
                a.classList.remove("ui-tag--selected");
            } else {
                change(true);
                a.classList.add("ui-tag--selected");
            }
        });
    }

    function addListTagFilter(ul, text, filterKey, filterValue) {
        let selected = hasRadarFilter(radarFilter, filterKey, filterValue);
        return addListTag(ul, text, selected, checked => {
            if (checked) {
                addRadarFilter(radarFilter, filterKey, filterValue);
            } else {
                removeRadarFilter(radarFilter, filterKey, filterValue);
            }
            refreshFilter();
        });
    }

    function addInput(ul) {
        return addElement(ul, `
            <div class="filter__group">
                <div class="js-fulltext-input filter__group--fulltext">
                    <div class="Container--uQSLs layout layout--v-center">
                        <div class="layout-item layout-item--consume">
                            <input class="js-input Input--EicBC input" autocorrect="off" autocapitalize="off" spellcheck="false">
                        </div>
                    </div>
                </div>
            </div>`).querySelector("input");
    }

    addSectionListMulti("lookingForOther", "openTo", "filter[personal][looking_for][]",
        ["SEXDATES", "FRIENDSHIP", "RELATIONSHIP"]);

    addSectionListMulti("bodyType", "bodyType", "filter[personal][body_type][]",
        ["SLIM", "AVERAGE", "ATHLETIC", "MUSCULAR", "BELLY", "STOCKY"]);
    addSectionListMulti("ethnicity", "ethnicity", "filter[personal][ethnicity][]",
        ["CAUCASIAN", "ASIAN", "LATIN", "MEDITERRANEAN", "BLACK", "MIXED", "ARAB", "INDIAN"]);
    addSectionListMulti("hairLength", "hairLength", "filter[personal][hair_length][]",
        ["SHAVED", "SHORT", "AVERAGE", "LONG", "PUNK"]);
    addSectionListMulti("hairColor", "hairColor", "filter[personal][hair_color][]",
        ["BLOND", "LIGHT_BROWN", "BROWN", "BLACK", "GREY", "OTHER", "RED"]);
    addSectionListMulti("beard", "beard", "filter[personal][beard][]",
        ["DESIGNER_STUBBLE", "MOUSTACHE", "GOATEE", "FULL_BEARD", "NO_BEARD"]);
    addSectionListMulti("eyeColor", "eyeColor", "filter[personal][eye_color][]",
        ["BLUE", "BROWN", "GREY", "GREEN", "OTHER"]);
    addSectionListMulti("bodyHair", "bodyHair", "filter[personal][body_hair][]",
        ["SMOOTH", "SHAVED", "LITTLE", "AVERAGE", "VERY_HAIRY"]);
    addSectionListMulti("gender", "gender", "filter[personal][gender_orientation][gender][]",
        ["MAN", "TRANS_MAN", "TRANS_WOMAN", "NON_BINARY", "OTHER"]);
    addSectionListMulti("orientation", "orientation", "filter[personal][gender_orientation][orientation][]",
        ["GAY", "BISEXUAL", "QUEER", "STRAIGHT", "OTHER"]);
    addSectionListMulti("smoker", "smoker", "filter[personal][smoker][]",
        ["NO", "SOCIALLY", "YES"]);
    addSectionListMulti("tattoos", "tattoos", "filter[personal][tattoo][]",
        ["A_FEW", "A_LOT", "NO"]);
    addSectionListMulti("piercings", "piercings", "filter[personal][piercing][]",
        ["A_FEW", "A_LOT", "NO"]);
    addSectionListMulti("relationship", "relationship", "filter[personal][relationship][]",
        ["SINGLE", "PARTNER", "OPEN", "MARRIED"]);

    addSectionListMulti("analPosition", "analPosition", "filter[sexual][anal_position][]",
        ["TOP_ONLY", "MORE_TOP", "VERSATILE", "MORE_BOTTOM", "BOTTOM_ONLY", "NO"]);
    addSectionListMulti("dick", "dick", "filter[sexual][dick_size][]",
        ["S", "M", "L", "XL", "XXL"]);
    addSectionListMulti("concision", "concision", "filter[sexual][concision][]",
        ["CUT", "UNCUT"]);
    addSectionListMulti("fetish", "fetish", "filter[sexual][fetish][]",
        ["LEATHER", "SPORTS", "SKATER", "RUBBER", "UNDERWEAR", "SKINS", "BOOTS", "LYCRA", "UNIFORM", "FORMAL",
            "TECHNO", "SNEAKERS", "JEANS", "DRAG", "WORKER", "CROSSDRESSING"]);
    addSectionListMulti("dirty", "dirty", "filter[sexual][dirty_sex][]",
        ["YES", "NO", "WS_ONLY"]);
    addSectionListMulti("fisting", "fisting", "filter[sexual][fisting][]",
        ["ACTIVE", "ACTIVE_PASSIVE", "PASSIVE", "NO"]);
    addSectionListMulti("sm", "sm", "filter[sexual][sm][]",
        ["YES", "SOFT", "NO"]);
    addSectionListMulti("saferSex", "saferSex", "filter[sexual][safer_sex][]",
        ["ALWAYS", "NEEDS_DISCUSSION", "CONDOM", "PREP", "PREP_AND_CONDOM", "TASP"]);

    addSectionListMulti("interests", "interests", "filter[hobby][interests][]",
        ["ART", "BOARDGAME", "CAR", "COLLECT", "COMPUTER", "COOK", "DANCE", "FILM", "FOTO", "GAME", "LITERATURE",
            "MODELING", "MOTORBIKE", "MUSIC", "NATURE", "POLITICS", "TV"], false);

    const section = addSectionList("other");

    const coordInput = addInput(section);
    coordInput.type = "text";
    coordInput.placeholder = translate("latLong");
    if ("filter[location][lat]" in radarFilter && "filter[location][long]" in radarFilter) {
        coordInput.value = `${radarFilter["filter[location][lat]"]}, ${radarFilter["filter[location][long]"]}`;
    }
    coordInput.addEventListener("change", e => {
        removeRadarFilter(radarFilter, "filter[location][lat]");
        removeRadarFilter(radarFilter, "filter[location][long]");
        const sep = e.target.value.indexOf(", ");
        if (sep != -1) {
            const lat = parseFloat(e.target.value);
            const long = parseFloat(e.target.value.substring(sep + 2));
            if (!isNaN(lat) && !isNaN(long)) {
                addRadarFilter(radarFilter, "filter[location][lat]", lat.toString());
                addRadarFilter(radarFilter, "filter[location][long]", long.toString());
            }
        }
        refreshFilter();
    });

    const radiusInput = addInput(section);
    radiusInput.type = "text";
    radiusInput.placeholder = translate("customRadius");
    if ("filter[location][radius]" in radarFilter) {
        const radius = radarFilter["filter[location][radius]"];
        radiusInput.value = measurementSystem === "METRIC"
            ? radius / 1000
            : round(radius * M2MI, 1);
    }
    radiusInput.addEventListener("change", e => {
        removeRadarFilter(radarFilter, "filter[location][radius]");
        if (parseInt(e.target.value)) {
            const radius = measurementSystem === "METRIC"
                ? e.target.value * 1000
                : e.target.value / M2MI;
            addRadarFilter(radarFilter, "filter[location][radius]", radius);
        }
        refreshFilter();
    });

    addListTagFilter(section, "bedAndBreakfast", "filter[bed_and_breakfast_filter]", "ONLY");
    addListTagFilter(section, "travelersOnly", "filter[travellers_filter]", "TRAVELLERS_ONLY");
    addListTagFilter(section, "speakingMyLanguage", "filter[personal][speaks_my_languages]", "true");
}

function xhrApplyRadarFilter(url) {
    if (!getEnhancedFilter()) {
        return url;
    }

    // Decode existing parameters.
    const sep = url.indexOf("?");
    const baseUrl = url.substring(0, sep);
    const query = url.substring(sep + 1);

    let keyValues = [];
    for (const param of query.split("&")) {
        let [key, value] = param.split("=");
        key = decodeURI(key);

        // Ignore pagination and "Explore" views as filters don't fully work in them.
        if (key == "cursor" || key == "length" && value == 48) {
            return url;
        }

        keyValues.push([key, value]);
    }

    // Overwrite with custom parameters.
    let filter = packRadarFilter(keyValues);
    filter = { ...filter, ...radarFilter };

    // Encode new parameters.
    url = `${baseUrl}?`;
    for (const [key, value] of unpackRadarFilter(filter)) {
        url += `${encodeURI(key)}=${value}&`;
    }
    return url.slice(0, -1);
}

onXhr("open", "GET", "v4/hunqz/profiles", e => e.url = xhrApplyRadarFilter(e.url));
onXhr("open", "GET", "v4/profiles", e => e.url = xhrApplyRadarFilter(e.url));
onXhr("open", "GET", "v4/profiles/popular", e => e.url = xhrApplyRadarFilter(e.url));

onXhr("send", "PUT", "v4/settings/interface/bluebird", e => {
    // Changed filter.
    const id = e.body.search_filter.id;
    radarFilter = getSavedRadarFilter(id);
    replaceFilterContainer(document.querySelector(".js-quick-filter").parentNode);
});
onXhr("load", "DELETE", "v4/search/filters/*", e => {
    // Deleted filter.
    const id = e.args[0];
    setSavedRadarFilter(id);
});
onXhr("load", "POST", "v4/search/filters", e => {
    // Created filter.
    const id = e.body.id;
    setSavedRadarFilter(id, radarFilter);
});

// ---- Tiles ----

addCss(`
/* fix jumping fade during load */
div.BIG::before, div.SMALL::before {
    inset: 60% 0px 0px !important;
}

/* tile description truncation */
.tile p[class^="SpecialText-"] {
    white-space: var(--tile-headline-white-space);
}

.ra_tile_headline {
    color: rgb(255, 255, 255);
    font-family: Inter, Helvetica, Arial, "Open Sans", sans-serif;
    font-size: 0.8125rem;
    font-weight: 400;
    line-height: 1.23077;
    overflow: hidden;
    text-overflow: ellipsis;
    text-shadow: rgba(0, 0, 0, 0.32) 0px 1px 1px, rgba(0, 0, 0, 0.42) 1px 1px 1px;
    white-space: var(--tile-headline-white-space);
}

.ra_tile_tag_row {
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem;
    margin-top: 0.25rem;
}

.ra_tile_tag {
    background-color: rgb(46, 46, 46);
    border-radius: 2px;
    box-shadow: rgba(0, 0, 0, 0.32) 0px 0px 1px, rgba(0, 0, 0, 0.24) 0px 0px 1px, rgba(0, 0, 0, 0.16) 0px 0px 3px;
    color: rgba(255, 255, 255, 0.87);
    font-family: Inter, Helvetica, Arial, "Open Sans", sans-serif;
    font-size: 0.8125rem;
    font-weight: 400;
    line-height: 1.23077;
    padding: 0px 2px;
}

.ra_tile_tag_new {
    color: rgb(0, 209, 0);
}
`);

onElement(`.tile:not(div[class*="tile--loading--"]) > .reactView > a`, a => {
    const tile = a.closest(".tile");
    if (!tile || tile.classList.contains("tile--small")) {
        return;
    }

    // Find profile cached for this tile.
    const username = getUsernameFromHref(a.href);
    const profile = profileCache[username];
    if (!profile) {
        return;
    }

    // Add custom tags and headline.
    let tagRow;
    let tagClasses;
    let tagNewClasses;
    function addTag(text, isNew) {
        if (text) {
            addElement(tagRow, `<span class="${isNew ? tagNewClasses : tagClasses}">${text}</span>`);
        }
    }

    const inner = a.firstChild;
    if (inner.classList.contains("BIG")) {
        // Find existing tag elements and classes.
        const lastTag = tile.querySelector(`span[class^="SpecialText-"]:last-child`);
        if (!lastTag) {
            return;
        }
        tagRow = lastTag.parentNode;
        tagClasses = lastTag.classList;
        tagNewClasses = tagRow.firstChild.classList;
        // Clear existing tags.
        tagRow.replaceChildren();
    } else {
        const container = inner.lastChild;
        // Create headline.
        if (profile.headline) {
            addElement(container, `<p class="ra_tile_headline">${profile.headline}</p>`);
        }
        // Create tag row.
        tagRow = addElement(container, `<div class="ra_tile_tag_row">`);
        tagClasses = "ra_tile_tag";
        tagNewClasses = tagClasses + " ra_tile_tag_new";
    }

    // Add tags.
    if (tagNewClasses.value !== tagClasses.value) {
        addTag(translate("new"), true);
    }
    const personal = profile.personal;
    if (personal) {
        if (tileDetails.has("age")) addTag(personal.age);
        if (tileDetails.has("bodyHair")) addTag(getProfileEnum("bodyHair", personal.body_hair));
        if (tileDetails.has("height")) addTag(getProfileHeight(personal.height));
        if (tileDetails.has("weight")) addTag(getProfileWeight(personal.weight));
        if (tileDetails.has("bmi")) addTag(getProfileBmi(personal.height, personal.weight));
        if (tileDetails.has("ageRange")) addTag(getProfileAgeRange(personal.target_age, true));
        if (tileDetails.has("bodyType")) addTag(getProfileEnum("bodyType", personal.body_type));
        if (tileDetails.has("ethnicity")) addTag(getProfileEnum("ethnicity", personal.ethnicity));
        if (tileDetails.has("relationship")) addTag(getProfileEnum("relationship", personal.relationship));
    }
    const sexual = profile.sexual;
    if (sexual) {
        if (tileDetails.has("analPosition")) addTag(getProfileEnum("analPosition", sexual.anal_position));
        if (tileDetails.has("dick")) addTag(getProfileDick(sexual.dick_size, sexual.concision));
        if (tileDetails.has("saferSex")) addTag(getProfileEnum("saferSex", sexual.safer_sex));
        if (tileDetails.has("dirty")) addTag(getProfileEnum("dirty", sexual.dirty_sex));
        if (tileDetails.has("sm")) addTag(getProfileEnum("sm", sexual.sm));
        if (tileDetails.has("fisting")) addTag(getProfileEnum("fisting", sexual.fisting));
    }
});

// ---- Messaging ----

addCss(`
/* message list truncation */
#messenger div[class^="TruncateBlock__Content-sc-"] {
    -webkit-line-clamp: var(--message-line-clamp);
}
`);

onElement(`.js-send-region.layout-item > div`, el => {
    el.addEventListener("keydown", e => {
        // Prevent site event handler from sending message or typing notifications.
        const enter = e.key === "Enter";
        const send = enter && (getSendEnter() || e.ctrlKey);
        const allow = send || getTypingNotifications() && !enter;
        if (!allow) {
            e.stopPropagation();
        }
    }, true);
});

// ---- Albums ----

let changeProfilePic = false;

addCss(`
.ra_picture_text {
    bottom: -14px;
    left: 72px;
    position: absolute;
    z-index: 1;
}

/* ensure images are not cropped */
div[role='dialog'] > div > main > ul > li > img {
    object-fit: contain;
}
`);

onElement(`div[role='dialog'] > div > main > ul > li > img`, el => {
    // Get cached user profile storing album information.
    const username = getUsernameFromHref(window.location.href);
    const profile = profileCache[username];
    if (!profile) {
        return;
    }

    // Get image url.
    const urlToken = el.src.match("/img/usr/original/0x0/([a-f0-9]*)\.jpg")[1];

    // Add upload date.
    for (const album of profile.albums.items) {
        for (const picture of album.pictures?.items ?? []) {
            if (picture.url_token === urlToken) {
                addElement(el.parentNode, `<p class="ra_picture_text">${formatTime(picture.uploaded_at)}</p>`);
                return;
            }
        }
    }
});

onElement(`li#picture_menu_set-as-main-profile-picture`, el => {
    const button = el.querySelector("button");

    // Only allow profile pic being changed when manually clicking this button.
    button.parentNode.addEventListener("click", e => {
        changeProfilePic = true;
    }, true);
});

onXhr("send", "PUT", "v4/profiles/me", e => {
    // Prevent automatic profile picture change when rearranging pictures.
    if (changeProfilePic) {
        changeProfilePic = false;
    } else if (e.body.preview_pic_id) {
        e.cancel = true;
    }
});

// ---- Settings ----

addCss(`
#version {
    color: white;
    display: block;
}
`);

onElement(`li.js-settings > div.accordion > ul.js-list`, el => {
    // Add extension menu item.
    const linkClass = el.querySelector("a").className;
    const link = addElement(el, `<li>
        <div>
            <a class="${linkClass}" href="/me/romeoadditions">${GM_info.script.name}</a>
        </div>
    </li>`);
    link.addEventListener("click", e => {
        if (link.classList.contains("is-selected")) {
            link.classList.remove("is-selected");
        } else {
            link.classList.add("is-selected");
            setTimeout(() => openSettingsPane(el, link)); // delayed execution to force open panel
        }
    });
    // Deselect menu item if others are clicked.
    for (const linkOther of el.querySelectorAll("li")) {
        if (linkOther !== link) {
            linkOther.addEventListener("click", e => link.classList.remove("is-selected"));
        }
    }
});

onElement(`main[aria-label="Settings"] div.reactView--autoHeight > p[class^="MiniText-sc-"]`, el => {
    el.innerHTML += `<a id="version" href="https://greasyfork.org/en/scripts/419514" target="blank">${GM_info.script.name} ${GM_info.script.version}</a>`;
});

function openSettingsPane(ul, link) {
    // Open pane.
    const layerContent = document.querySelector("#offcanvas-nav > .js-layer-content");
    layerContent.classList.add("is-open");

    // Create UI.
    const pane = layerContent.querySelector(".js-side-content");
    pane.replaceChildren();
    const p = addElement(pane, `
        <div class="layout layout--vertical layout--consume">
            <div class="layout-item layout-item--consume layout layout--vertical">

                <div class="js-header layout-item l-hidden-md-lg">
                    <div class="layer-header layer-header--primary">
                        <a class="back-button l-hidden-md-lg l-tappable js-back marionette" href="/me">
                            <span class="js-back-icon icon icon-back icon-large"></span>
                        </a>
                        <div class="layer-header__title">
                            <h2>${GM_info.script.name}</h2>
                        </div>
                    </div>
                </div>
                <div class="layout-item settings__navigation p l-hidden-sm">
                    <div class="js-title typo-section-navigation">${GM_info.script.name}</div>
                </div>

                <div class="layout-item layout-item--consume">
                    <div class="js-content js-scrollable fit scrollable">
                        <div id="ra_settings_p" class="p"></div>
                    </div>
                </div>
            </div>
        </div>`).querySelector("#ra_settings_p");

    function addSection(title) {
        return addElement(p, `
            <div class="settings__key">
                <div>
                    <span>${translate(title)}</span>
                </div>
                <div class="separator separator--alt separator--narrow [ mb ] "></div>
            </div>`);
    }
    function addCheckbox(section, text, desc) {
        const input = addElement(section, `
            <div class="layout layout--v-center">
                <div class="layout-item [ 6/12--sm ]">
                    <span>${translate(text)}</span>
                </div>
                <div class="layout-item [ 6/12--sm ]">
                    <div class="js-toggle-show-headlines pull-right">
                        <div>
                            <span class="ui-toggle ui-toggle--default ui-toggle--right">
                                <input class="ui-toggle__input" type="checkbox" id="ra_${text}">
                                <label class="ui-toggle__label" for="ra_${text}" style="touch-action: pan-y; user-select: none; -webkit-user-drag: none; -webkit-tap-highlight-color: rgba(0, 0, 0, 0);"></label>
                            </span>
                        </div>
                    </div>
                </div>
            </div>`).querySelector("input");
        addElement(section, `
            <div>
                <div class="settings__description">${translate(desc)}</div>
            </div>`);
        return input;
    }
    function addNumber(section, text, min, max) {
        return addElement(section, `
            <div class="layout layout--v-center">
                <div class="layout-item [ 6/12--sm ] mv-">
                    <span>${translate(text)}</span>
                </div>
                <div class="layout-item [ 6/12--sm ] mv-">
                    <input class="input input--block" type="number" min="${min}" max="${max}"/>
                </div>
            </div>`).querySelector("input");
    }
    function addTagList(section) {
        return addElement(section, `
            <div class="mv js-grid-stats-selector">
                <div>
                    <ul class="js-list tags-list tags-list--centered"/>
                </div>
            </div>`).querySelector("ul");
    }
    function addTag(ul, tag, text, selected, change) {
        const li = addElement(ul, `
            <li class="tags-list__item">
                <a class="js-tag ui-tag ui-tag--removable" href="#">
                    <span class="ui-tag__label">${text}</span>
                </a>
            </li>`);
        const a = li.querySelector("a");
        if (selected) {
            a.classList.add("ui-tag--selected");
        }
        li.addEventListener("click", e => {
            e.preventDefault();
            if (a.classList.contains("ui-tag--selected")) {
                change({ tag: tag, checked: false });
                a.classList.remove("ui-tag--selected");
            } else {
                change({ tag: tag, checked: true });
                a.classList.add("ui-tag--selected");
            }
        });
    }

    // Add common section.
    const commonOptionsSection = addSection("commonOptions");

    const systemMessages = addCheckbox(commonOptionsSection, "systemMessages", "systemMessagesDesc");
    systemMessages.checked = getSystemMessages();
    systemMessages.addEventListener("change", e => setSystemMessages(e.target.checked));

    // Add user lists section.
    const tilesSection = addSection("tiles");

    const tileCount = addNumber(tilesSection, "tileCount", 0, 50);
    tileCount.value = getTileCount();
    tileCount.addEventListener("change", e => setTileCount(parseInt(e.target.value)));

    const enhancedFilter = addCheckbox(tilesSection, "enhancedFilter", "enhancedFilterDesc");
    enhancedFilter.checked = getEnhancedFilter();
    enhancedFilter.addEventListener("change", e => setEnhancedFilter(e.target.checked));

    const fullHeadlines = addCheckbox(tilesSection, "fullHeadlines", "fullHeadlinesDesc");
    fullHeadlines.checked = getFullHeadlines();
    fullHeadlines.addEventListener("change", e => setFullHeadlines(e.target.checked));

    const enhancedTiles = addCheckbox(tilesSection, "enhancedTiles", "enhancedTilesDesc");
    enhancedTiles.checked = getEnhancedTiles();
    enhancedTiles.addEventListener("change", e => setEnhancedTiles(e.target.checked));

    const tileDetailsList = addTagList(tilesSection, "tileDetailsList");
    for (const tileDetail of ["age", "height", "weight", "bmi", "ageRange",
        "bodyHair", "bodyType", "ethnicity", "relationship", "analPosition",
        "dick", "saferSex", "dirty", "sm", "fisting"]) {
        addTag(tileDetailsList, tileDetail, translate(tileDetail), tileDetails.has(tileDetail), e => setTileDetail(e.tag, e.checked));
    }

    // Add messages section.
    const messagesSection = addSection("messages");

    const fullMessages = addCheckbox(messagesSection, "fullMessages", "fullMessagesDesc");
    fullMessages.checked = getFullMessages();
    fullMessages.addEventListener("change", e => setFullMessages(e.target.checked));

    const typingNotifications = addCheckbox(messagesSection, "typingNotifications", "typingNotificationsDesc");
    typingNotifications.checked = getTypingNotifications();
    typingNotifications.addEventListener("change", e => setTypingNotifications(e.target.checked));

    const sendEnter = addCheckbox(messagesSection, "sendEnter", "sendEnterDesc");
    sendEnter.checked = getSendEnter();
    sendEnter.addEventListener("change", e => setSendEnter(e.target.checked));

    // Add hidden users section.
    const hiddenUsersSection = addSection("hiddenUsers");

    const hideContacts = addCheckbox(hiddenUsersSection, "hideContacts", "hideContactsDesc");
    hideContacts.checked = getHideContacts();
    hideContacts.addEventListener("change", e => setHideContacts(e.target.checked));

    const hideMessages = addCheckbox(hiddenUsersSection, "hideMessages", "hideMessagesDesc");
    hideMessages.checked = getHideMessages();
    hideMessages.addEventListener("change", e => setHideMessages(e.target.checked));

    const hideVisits = addCheckbox(hiddenUsersSection, "hideVisits", "hideVisitsDesc");
    hideVisits.checked = getHideVisits();
    hideVisits.addEventListener("change", e => setHideVisits(e.target.checked));

    const hideActivities = addCheckbox(hiddenUsersSection, "hideActivities", "hideActivitiesDesc");
    hideActivities.checked = getHideActivities();
    hideActivities.addEventListener("change", e => setHideActivities(e.target.checked));

    const hideLikes = addCheckbox(hiddenUsersSection, "hideLikes", "hideLikesDesc");
    hideLikes.checked = getHideLikes();
    hideLikes.addEventListener("change", e => setHideLikes(e.target.checked));

    const inMinAge = addNumber(hiddenUsersSection, "minAge", 18, 99);
    const inMaxAge = addNumber(hiddenUsersSection, "maxAge", 18, 99);
    let minAge = getHiddenMinAge();
    let maxAge = getHiddenMaxAge();
    inMinAge.value = minAge;
    inMaxAge.value = maxAge;
    inMinAge.addEventListener("change", e => {
        minAge = parseInt(e.target.value);
        setHiddenMinAge(minAge);
        if (minAge > maxAge) {
            maxAge = minAge;
            setHiddenMaxAge(maxAge);
            inMaxAge.val(maxAge);
        }
    });
    inMaxAge.addEventListener("change", e => {
        maxAge = parseInt(e.target.value);
        setHiddenMaxAge(maxAge);
        if (maxAge < minAge) {
            minAge = maxAge;
            setHiddenMinAge(minAge);
            inMinAge.val(minAge);
        }
    });

    createList(hiddenUsersSection, {
        onGet: () => Array.from(getHiddenUsers()).sort(Intl.Collator().compare),
        onAdd: e => setUserHidden(e, true),
        onRemove: e => setUserHidden(e, false)
    });
}

// ---- Load ----

setSystemMessages(getSystemMessages());
setFullHeadlines(getFullHeadlines());
setTileCount(getTileCount());
setFullMessages(getFullMessages());
tileDetails = getTileDetails();

hookContextMenu();
hookDom();
hookPreviewHistory();
hookXhr();