// ==UserScript==
// @name Romeo Additions
// @name:de Romeo Additions
// @namespace https://greasyfork.org/en/users/723211-ray/
// @version 7.9.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
// @grant none
// @iconURL https://www.romeo.com/assets/favicons/711cd1957a9d865b45974099a6fc413e3bd323fa5fc48d9a964854ad55754ca1/favicon.ico
// @supportURL https://greasyfork.org/en/scripts/419514-romeo-additions
// ==/UserScript==
const CM2FT = 0.03280839895;
const KG2LBS = 2.20462262185;
const M2MI = 0.0006213712;
function decodeUrl(url)
{
const [base, paramsText] = url.split("?");
const params = new URLSearchParams(paramsText);
return [base, params];
}
function encodeUrl(base, params)
{
return `${base}?${new URLSearchParams(params)}`;
}
function escapeHtml(unsafe)
{
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function formatTime(str)
{
const date = new Date(Date.parse(str));
const lang = getLang();
return `${date.toLocaleDateString(lang)} ${date.toLocaleTimeString(lang)}`;
}
function getLang()
{
return document.documentElement.getAttribute("lang") || "en";
}
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 getCssBackgroundImageUrl(style)
{
return style.match(/"(.+)"/)[1];
}
function setCssProp(name, value)
{
document.documentElement.style.setProperty(name, value);
}
// ---- DOM ----
const domHooks = {};
function initDom()
{
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 onDom(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",
},
blockUser:
{
de: "Benutzer blockieren",
en: "Block user",
},
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?",
},
concision:
{
de: "Beschneidung",
en: "Concision",
},
concision_CUT:
{
de: "Beschnitten",
en: "Cut",
},
concision_UNCUT:
{
de: "Unbeschnitten",
en: "Uncut",
},
customRadius:
{
de: "Benutzerdefinierter Radius",
en: "Custom Radius",
},
deleteUnread:
{
de: "Ungelesen löschen",
en: "Delete unread"
},
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",
},
discoverFilter:
{
de: "Entdecken filtern",
en: "Filter Discover",
},
discoverFilterDesc:
{
de: "Wendet Radar-Filter auf die Entdecken-Seite an (außer Eyecandy).",
en: "Applies radar filter on the Discover page (except Eyecandy).",
},
display:
{
de: "Anzeige",
en: "Display",
},
distance:
{
de: "Entfernung",
en: "Distance",
},
filter:
{
en: "Filter",
},
enhancedFilter:
{
de: "Erweiterter Filter",
en: "Extended filter",
},
enhancedFilterDesc:
{
de: "Erlaubt Radar-Ergebnisse nach allen Details zu filtern.",
en: "Allows to filter radar results by additional details.",
},
enhancedImages:
{
de: "Hochauflösende Bilder",
en: "High-resolution images",
},
enhancedImagesDesc:
{
de: "Zeigt Kachelbilder in maximaler Auflösung.",
en: "Shows tile images in maximum resolution.",
},
enhancedTiles:
{
de: "Große Kacheln erzwingen",
en: "Force big grid",
},
enhancedTilesDesc:
{
de: "Zeigt alle Benutzer in großen Kacheln.",
en: "Shows all users in big tiles.",
},
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 lange Profilüberschriften vollständig.",
en: "Shows long profile headlines completely.",
},
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 ausblenden",
en: "Hide in activities",
},
hideContacts:
{
de: "In Kontakten ausblenden",
en: "Hide in contacts",
},
hideFriends:
{
de: "In Freunden ausblenden",
en: "Hide in friends",
},
hideLikes:
{
de: "In Likes ausblenden",
en: "Hide in likes",
},
hideMessages:
{
de: "In Nachrichten ausblenden",
en: "Hide in messages",
},
hideUser:
{
de: "Benutzer ausblenden",
en: "Hide user",
},
hideVisits:
{
de: "In Besuchern ausblenden",
en: "Hide in visitors",
},
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",
},
searchFilter:
{
de: "Suche filtern",
en: "Filter Search",
},
searchFilterDesc:
{
de: "Wendet Radar-Filter auf die Suchergebnisse an.",
en: "Applies radar filter on the search results.",
},
sendEnter:
{
de: "Enter sendet Nachricht",
en: "Enter sends message",
},
sendEnterDesc:
{
de: "Wenn deaktiviert erzeugt Enter einen Absatz und Strg+Enter sendet 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",
},
socialSmoker:
{
de: "Raucht selten",
en: "Social Smoker",
},
speakingMyLanguage:
{
de: "Spricht meine Sprache",
en: "Speaking my language",
},
systemMessages:
{
de: "Systemnachrichten",
en: "System messages",
},
systemMessagesDesc:
{
de: "Erlaubt Popups wie Standort- oder Fehlermeldungen.",
en: "Allows popups like GPS or error messages.",
},
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: "Kachelspalten (0 für Standard)",
en: "Tile columns (0 for default)",
},
typingNotifications:
{
de: "Tippbenachrichtigungen",
en: "Typing notifications",
},
typingNotificationsDesc:
{
de: "Ob Empfänger die Eingabe einer Nachricht sehen können.",
en: "Whether receivers can see that a message is being written.",
},
viewFullImage:
{
de: "Bild anzeigen",
en: "Preview image",
},
viewProfile:
{
de: "Profilvorschau anzeigen",
en: "Preview profile",
},
weight:
{
de: "Gewicht",
en: "Weight",
}
};
function translate(key)
{
const translations = strings[key];
return translations
? translations[getLang()] || 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 getDiscoverFilter()
{
return load("discoverFilter", false);
}
function getEnhancedFilter()
{
return load("enhancedFilter", true);
}
function getEnhancedImages()
{
return load("enhancedImages", 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 getHideFriends()
{
return load("hideFriends", true);
}
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 getSearchFilter()
{
return load("searchFilter", false);
}
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 setDiscoverFilter(value)
{
save("discoverFilter", value);
}
function setEnhancedFilter(value)
{
save("enhancedFilter", value);
}
function setEnhancedImages(value)
{
save("enhancedImages", 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 setHideFriends(value)
{
save("hideFriends", 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 setSearchFilter(value)
{
save("searchFilter", value);
}
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)) - 1px);
}
/* discover */
section.js-main-stage > main main > section > ul
{
grid-template-columns: repeat(var(--tile-count), 1fr) !important;
}
/* radar desktop */
.search-results__item
{
padding-bottom: var(--tile-size) !important;
width: var(--tile-size) !important;
}
/* radar mobile - starts at 768px where .search-results__item turns 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;
}
}
/* visitors */
#cruise main > ul
{
grid-template-columns: repeat(var(--tile-count), 1fr);
}
`);
}
}
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)));
}
// ---- Fetch/XHR ----
function addHook(hooks, func, method, route, callback)
{
if (!hooks[func])
hooks[func] = {};
if (!hooks[func][method])
hooks[func][method] = {};
hooks[func][method][route] = callback;
}
function callHook(hooks, func, e)
{
// Log XHR.
if (func !== "open")
{
const prefix = func === "load" || func === "recv" ? 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 (hooks[func] && hooks[func][e.method])
{
for (const [hookRoute, callback] of Object.entries(hooks[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;
}
// Fetch
const fetchHooks = {};
const realFetch = window.fetch;
function initFetch()
{
async function getJsonBody(r)
{
// Use conversion to arrayBuffer to check if body exists as Firefox does not have a "body" property.
const buffer = await r.clone().arrayBuffer();
if (buffer.byteLength)
{
try
{
return JSON.parse(new TextDecoder().decode(buffer));
}
catch
{
return null; // not JSON, currently not interested
}
}
}
window.fetch = async (request, init) =>
{
if (!(request instanceof Request))
return await realFetch(request, init);
// Manipulate request.
let e =
{
body: await getJsonBody(request),
cancel: false,
method: request.method,
url: request.url,
};
if (e.body === null)
return realFetch(request, init);
if (callHook(fetchHooks, "send", e) && e.cancel)
return;
// Send request and receive response.
const response = await realFetch(e.url, {
body: JSON.stringify(e.body),
cache: request.cache,
credentials: request.credentials,
headers: request.headers,
integrity: request.integrity,
keepalive: request.keepalive,
method: e.method,
mode: request.mode,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
});
// Manipulate response.
e =
{
body: await getJsonBody(response),
cancel: false,
url: request.url,
method: request.method,
status: response.status,
};
if (e.body === null)
return response;
if (callHook(fetchHooks, "recv", e) && e.cancel)
{
e.body = null;
e.status = 404;
}
return new Response(JSON.stringify(e.body), { headers: response.headers, status: e.status });
};
}
function onFetch(func, method, route, callback)
{
addHook(fetchHooks, func, method, route, callback);
}
// XHR
const xhrHooks = {};
const xhrOpened = {};
function initXhr()
{
// 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(xhrHooks, "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(xhrHooks, "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(xhrHooks, "send", e) && e.body)
body = json ? JSON.stringify(e.body) : body;
if (!e.cancel)
realSend.apply(this, arguments);
};
}
function onXhr(func, method, route, callback)
{
addHook(xhrHooks, func, method, route, callback);
}
// ---- Lists ----
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();
}
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;
}
`);
// ---- Romeo ----
const apiKey = atob("QVM4YnpHSExBOFk5QlhGNzNpRE51UUJIZUVPMFVLamY=");
let sessionId;
async function blockUser(username)
{
if (!username)
return false;
const profileId = profileCache[username].id;
if (!profileId)
return false;
const response = await sendFetch("POST", "/api/v4/profiles/blocked", { profile_id: profileId, note: "" },
`https://www.romeo.com/profile/${username}/report`);
return response.ok;
}
function getImageUrl(url, size)
{
const base = url.substring(0, url.indexOf("/img/usr/"));
const file = url.substring(url.lastIndexOf("/") + 1);
return size
? `${base}/img/usr/squarish/${size}x${size}/${file}`
: `${base}/img/usr/${file}`;
}
function getUsernameFromHref(href)
{
let start = href.indexOf("profile/");
if (start === -1)
start = href.indexOf("hunq/");
return href.substring(start).split("/")[1];
}
function sendFetch(method, url, body, referrer)
{
const options =
{
method: method,
cache: "no-cache",
credentials: "same-origin",
headers:
{
"x-api-key": apiKey,
"x-session-id": sessionId,
"x-site": "planetromeo"
}
};
if (body)
{
options.headers["content-type"] = "application/json";
options.body = JSON.stringify(body);
}
if (referrer)
options.referrer = referrer;
return realFetch(url, options);
}
function sendXhr(method, url)
{
return new Promise((resolve, reject) =>
{
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.onload = () =>
{
if (xhr.status >= 200 && xhr.status < 300)
resolve(xhr.response);
else
reject({ status: xhr.status, statusText: xhr.statusText });
};
xhr.onerror = () => reject({ status: xhr.status, statusText: xhr.statusText });
xhr.setRequestHeader("x-api-key", apiKey);
xhr.setRequestHeader("x-session-id", sessionId);
xhr.send();
});
}
function xhrHandleSession(reply)
{
// Determine session ID.
sessionId = reply.session_id;
// Apply settings.
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);
}
// Determine intiial Discover filter.
const filter = reply.bb_settings?.bluebird?.search_filter
?? reply.data?.search_filters;
if (filter)
{
radarFilter["filter[personal][age][max]"] = filter.personal.age.max;
radarFilter["filter[personal][age][min]"] = filter.personal.age.min;
radarFilter["filter[personal][height][max]"] = filter.personal.height.max;
radarFilter["filter[personal][height][min]"] = filter.personal.height.min;
radarFilter["filter[personal][weight][max]"] = filter.personal.weight.max;
radarFilter["filter[personal][weight][min]"] = filter.personal.weight.min;
if (!("filter[location][radius]" in radarFilter))
radarFilter["filter[location][radius]"] = filter.location.radius;
}
// 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
}
}
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 message at bottom of visitor grid */
main#visitors > section
{
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;
}
}
`);
onXhr("load", "GET", "v4/session", e => xhrHandleSession(e.body));
// ---- Menu ----
const menuHandlers = {};
let menuBg, menuUl, menuX, menuY;
function initMenu()
{
// Create context menu canceler.
menuBg = addElement(document.body, "<div id='ra_context_bg'></ul>");
menuBg.addEventListener("click", e => hideMenu());
// Create context menu.
menuUl = addElement(document.body, "<ul id='ra_context_ul'></ul>");
// Attach to events.
addEventListener("contextmenu", e =>
{
menuX = e.clientX;
menuY = e.clientY;
// Go through hierarchy of clicked elements.
for (const el of document.elementsFromPoint(menuX, menuY))
{
// 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(menuHandlers))
{
if (el.matches(key))
{
log(`opening menu '${key}'`);
value(el);
e.preventDefault();
return;
}
}
}
});
}
function showMenu(items)
{
menuBg.style.display = "block";
menuUl.replaceChildren();
for (const item of items)
{
const li = addElement(menuUl, `
<li class="ra_context_li">
<span class="icon icon-${item.icon}"></span>
${translate(item.text)}
</li>`);
li.addEventListener("click", e =>
{
hideMenu();
item.onclick();
});
}
menuUl.style.display = "block";
const maxX = window.innerWidth - menuUl.offsetWidth;
const maxY = window.innerHeight - menuUl.offsetHeight;
menuUl.style.left = Math.min(menuX, maxX) + "px";
menuUl.style.top = Math.min(menuY, maxY) + "px";
}
function hideMenu()
{
menuBg.style.display = "none";
menuUl.style.display = "none";
}
function onMenu(selector, handler)
{
menuHandlers[selector] = handler;
}
function menuItem(icon, text, onclick)
{
return { icon, text, onclick };
}
addCss(`
#ra_context_bg
{
background: transparent;
display: none;
height: 100%;
position: fixed;
width: 100%;
z-index: 100000;
}
#ra_context_ul
{
background: #232323;
border-radius: 1.125rem;
box-shadow: rgba(0, 0, 0, 0.32) 0px 0px 2px, rgba(0, 0, 0, 0.24) 0px 0px 1px, rgba(0, 0, 0, 0.16) 0px 0px 5px;
display: none;
font-family: Inter, Helvetica, Arial, "Open Sans", sans-serif;
font-size: 94%;
overflow: hidden;
position: absolute;
z-index: 100001;
}
.ra_context_li
{
border-color: transparent;
border-left: 2px solid transparent;
border-style: solid;
border-width: 1px 1px 1px 2px;
color: #FFF;
cursor: default;
padding: 9px 18px 10px 10px;
transition: background-color 200ms cubic-bezier(0, 0, 0.2, 1);
white-space: nowrap;
}
.ra_context_li:not(:first-child) {
border-top: 1px solid rgba(255, 255, 255, 0.16);
}
.ra_context_li .icon
{
margin: 4px;
}
.ra_context_li:hover
{
background: #2E2E2E;
}
@media screen and (max-width: 767px)
{
#ra_context_bg
{
background: rgba(0, 0, 0, 0.6);
}
#ra_context_ul
{
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
bottom: 0;
left: unset !important;
position: fixed;
top: unset !important;
width: 100%;
}
.ra_context_li
{
padding: 6px;
}
.ra_context_li .icon
{
font-size: 1.2rem;
margin: 8px;
}
}
`);
// ---- Previews ----
let previewLayer;
function createPreview(title)
{
const container = document.querySelector("#spotlight-container");
previewLayer = addElement(container, `
<div class="layer layer--spotlight" style="top:0;z-index:10000;">
<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 initPreviews()
{
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)
{
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 profile = profileCache[username];
if (!profile)
return;
const personal = profile.personal;
const sexual = profile.sexual;
if (pushHistory)
history.pushState({ ra_preview: "profile", username: username }, "");
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);
if (profile.last_login)
add(section, "lastLogin", formatTime(profile.last_login));
if (profile.location)
{
add(section, "location", `${profile.location.name}, ${profile.location.country}`);
addDistance(section, profile.location.distance, profile.location.sensor);
}
add(section, "profileId", profile.id);
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 (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));
}
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 (profile.personal?.profile_text)
{
const section = addSection(right, "aboutMe");
addElement(section, `<div id="ra_profile_text">${profile.personal.profile_text}</div>`);
}
}
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, 0.8fr) 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%;
}
}
`);
// ---- Profiles ----
const profileCache = {};
function cacheProfile(profileObject)
{
const existing = profileCache[profileObject.name];
const profile =
{
id: profileObject.id,
name: profileObject.name,
headline: profileObject.headline ?? existing?.headline,
last_login: profileObject.last_login ?? existing?.last_login,
location: profileObject.location ?? existing?.location,
online_status: profileObject.online_status ?? existing?.online_status,
pic: profileObject.preview_pic?.url_token ?? existing?.pic, // not available if no picture
personal: profileObject.profile?.personal ?? profileObject.personal ?? existing?.personal, // not available in activities
sexual: profileObject.profile?.sexual ?? profileObject.sexual ?? existing?.sexual, // not available in activities
albums: profileObject.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 filterItemsAndCacheProfiles(items, profileSelector, filter)
{
let newItems = [];
const hiddenMaxAge = getHiddenMaxAge();
const hiddenMinAge = getHiddenMinAge();
const hiddenNames = getHiddenUsers();
for (const item of items ?? [])
{
const profile = cacheProfile(profileSelector(item));
if (!filter || filterProfile(profile, hiddenMaxAge, hiddenMinAge, hiddenNames))
newItems.push(item);
}
return newItems;
}
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)
{
for (const [max, key] of Object.entries({
16: "bmiSevereThin",
17: "bmiModerateThin",
18.5: "bmiMildThin",
25: "bmiNormal",
30: "bmiPreObese",
35: "bmiObese1",
40: "bmiObese2",
99: "bmiObese3",
}))
{
if (bmi < max)
return result + ` / ${translate(key)}`;
}
}
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 = filterItemsAndCacheProfiles(e.body.items, x => x, true);
e.body.items_limited = e.body.items_total; // Remove PLUS ad tile.
// Show every user as a large tile.
if (getEnhancedTiles())
for (const item of e.body.items ?? [])
if (item.display)
item.display.large_tile = true;
}
function xhrHandleVisits(e)
{
e.body.items = filterItemsAndCacheProfiles(e.body.items, x => x, getHideVisits());
e.body.items_limited = e.body.items_total; // Restore PLUS-visible visitors.
}
onXhr("load", "GET", "v4/contacts", e =>
{
if (e.body.cursors)
e.body.items = filterItemsAndCacheProfiles(e.body.items, x => x.profile, getHideContacts());
});
onXhr("load", "GET", "v4/messages/conversations", e =>
{
e.body.items = filterItemsAndCacheProfiles(e.body.items, x => x.chat_partner, getHideMessages());
});
onXhr("load", "GET", "+/notifications/activity-stream", e =>
{
e.body = filterItemsAndCacheProfiles(e.body, x => x.partner, getHideActivities());
});
onFetch("recv", "GET", "v4/profiles", e => xhrHandleProfiles(e));
onFetch("recv", "GET", "v4/profiles/popular", e => xhrHandleProfiles(e));
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));
onFetch("recv", "GET", "v4/visitors", e => xhrHandleVisits(e));
onFetch("recv", "GET", "v4/visits", e => xhrHandleVisits(e));
onFetch("recv", "GET", "v4/reactions/cruise/likes", e =>
{
e.body.items = filterItemsAndCacheProfiles(e.body.items, x => x.profile, getHideLikes());
});
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));
onFetch("recv", "GET", "v4/profiles/*/linked", e =>
{
e.body.items = filterItemsAndCacheProfiles(e.body.items, x => x, getHideFriends());
});
onXhr("load", "GET", "v4/reactions/pictures/basic", e =>
{
e.body.items = filterItemsAndCacheProfiles(e.body.items, x => x.user_id, getHideLikes());
});
// ---- Filter ----
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(params)
{
let filter = {};
for (const [key, value] of params)
addRadarFilter(filter, key, value);
return filter;
}
function unpackRadarFilter(filter)
{
let params = [];
for (const key in filter)
{
if (isMultiRadarFilter(key))
{
for (const value of filter[key])
params.push([key, value]);
}
else
{
params.push([key, filter[key]]);
}
}
return params;
}
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 xhrApplyFilter(url, discover)
{
let [baseUrl, params] = decodeUrl(url);
let filter = packRadarFilter(params);
if (discover)
{
// Discover
if (!getDiscoverFilter())
return url;
}
else if ("filter[username]" in filter)
{
// Search (plain text only, #-prefixed text generates fulltext search).
if (!getSearchFilter())
return url;
}
else
{
// Radar
// Store Radar-only configurable parameters for Discover page.
function saveFilter(key)
{
if (filter[key])
radarFilter[key] = filter[key];
}
saveFilter("filter[personal][age][max]");
saveFilter("filter[personal][age][min]");
saveFilter("filter[personal][height][max]");
saveFilter("filter[personal][height][min]");
saveFilter("filter[personal][weight][max]");
saveFilter("filter[personal][weight][min]");
if (!getEnhancedFilter())
return url;
}
// Combine with custom parameters.
filter = { ...filter, ...radarFilter };
params = unpackRadarFilter(filter);
return encodeUrl(baseUrl, params);
}
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;
}
`);
onDom(`.js-quick-filter`, el =>
{
replaceFilterContainer(el.parentNode);
});
onFetch("send", "GET", "v4/profiles", e => e.url = xhrApplyFilter(e.url, true));
onXhr("open", "GET", "v4/hunqz/profiles", e => e.url = xhrApplyFilter(e.url));
onXhr("open", "GET", "v4/profiles", e => e.url = xhrApplyFilter(e.url));
onXhr("open", "GET", "v4/profiles/popular", e => e.url = xhrApplyFilter(e.url));
onXhr("send", "PUT", "v4/settings/interface/bluebird", e =>
{
// Changed filter.
const id = e.body.search_filter.id;
if (id)
radarFilter = getSavedRadarFilter(id);
const quickFilter = document.querySelector(".js-quick-filter")?.parentNode;
if (quickFilter)
replaceFilterContainer(quickFilter);
});
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 ----
const selTileDiscover = `section.js-content main > section > ul > li > a[href^="/profile/"]`; // li
const selTileRadarSmall = `div.js-search-results div.tile > div.reactView > a[href^="/profile/"]`; // div.tile (query first)
const selTileRadarLarge = `div.js-search-results div.tile--plus > div.reactView > a[href^="/profile/"]`; // div.search-results__item
const selTileRadarImage = `div.js-search-results div.tile > div.reactView > div.SMALL`; // div.tile
const selTileVisitors = `main#visitors a[href^="/profile/"]`; // li
const selTileVisited = `main#visited-grid a[href^="/profile/"]`; // li
const selTileLikes = `main#likers-list a[href^="/profile/"]`; // li
const selTileFriends = `section.js-profile-stats li > a[href^="/profile/"]`; // li
const selTileFriendsList = `main#friends-list li > a[href^="/profile/"]`; // li
const selTilePicLikes = `main#liked-by-list a[href^="/profile/"]`; // li
const selTileSearch = `div.js-results a[href^="/profile/"]`; // div.tile
const selTileActivity = `div.js-as-content div.tile a[href^="/profile/"]`; // div.listitem
function createTileMenu(el, username, removeOnHide, removeOnBlock)
{
return [
menuItem("search", "viewProfile", () => showProfilePreview(username)),
menuItem("hide-visit", "hideUser", () =>
{
setUserHidden(username, true);
if (removeOnHide)
el.style.display = "none";
}),
menuItem("illegal", "blockUser", () =>
{
blockUser(username);
if (removeOnBlock)
el.style.display = "none";
}),
];
}
addCss(`
/* fix jumping fade in mobile visitors/visits 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);
}
/* 2 friend list tile columns */
section.js-profile-stats ul, main#friends-list ul
{
grid-template-columns: 1fr 1fr;
}
.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);
}
`);
onDom([selTileDiscover, selTileRadarSmall, selTileVisitors, selTileVisited, selTileFriends, selTileFriendsList,
selTilePicLikes, selTileSearch].join(","), a =>
{
// 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 = a.querySelector(`div:last-child > 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));
if (tileDetails.has("smoker"))
{
if (personal.smoker === "YES")
addTag(translate("smoker"));
else if (personal.smoker === "SOCIALLY")
addTag(translate("socialSmoker"));
}
if (tileDetails.has("openTo") && personal.looking_for && personal.looking_for[0] !== "NO_ENTRY")
{
let text = "";
for (let openTo of personal.looking_for)
text += translateEnum("openTo", openTo)[0];
addTag(text);
}
}
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));
}
});
onDom(`img[src^="/img/usr/squarish/"][src$=".jpg"]`, el =>
{
if (getEnhancedImages())
{
const url = getImageUrl(el.src, 848);
el.src = url;
}
});
onDom(`*[style^='background-image: url("/img/usr/squarish/'][style$='.jpg");']`, el =>
{
if (getEnhancedImages())
{
const url = getImageUrl(getCssBackgroundImageUrl(el.style.backgroundImage), 848);
el.style.backgroundImage = `url("${url}")`;;
}
});
onMenu(selTileDiscover, a =>
{
const el = a.closest("li");
const username = getUsernameFromHref(a.href);
showMenu(createTileMenu(el, username, true, true));
});
onMenu(selTileRadarLarge, a =>
{
const el = a.closest("div.tile--plus").parentNode;
const username = getUsernameFromHref(a.href);
showMenu(createTileMenu(el, username, true, true));
});
onMenu(selTileRadarSmall, a =>
{
const el = a.closest("div.tile");
const username = getUsernameFromHref(a.href);
showMenu(createTileMenu(el, username, true, true));
});
onMenu(selTileRadarImage, el =>
{
const imageUrl = getImageUrl(getCssBackgroundImageUrl(el.style.backgroundImage));
showMenu([
menuItem("search", "viewFullImage", () => showImagePreview(imageUrl)),
]);
});
onMenu([selTileVisitors, selTileVisited].join(","), a =>
{
const el = a.closest("li");
const username = getUsernameFromHref(a.href);
showMenu(createTileMenu(el, username, getHideVisits(), true));
});
onMenu([selTileFriends, selTileFriendsList].join(","), a =>
{
const el = a.closest("li");
const username = getUsernameFromHref(a.href);
showMenu(createTileMenu(el, username, getHideFriends(), false));
});
onMenu([selTileLikes, selTilePicLikes].join(","), a =>
{
const el = a.closest("li");
const username = getUsernameFromHref(a.href);
showMenu(createTileMenu(el, username, getHideLikes(), true));
});
onMenu(selTileSearch, a =>
{
const el = a.closest("div.tile");
const username = getUsernameFromHref(a.href);
showMenu(createTileMenu(el, username, true, true));
});
onMenu(selTileActivity, a =>
{
const el = a.closest("div.listitem");
const username = getUsernameFromHref(a.href);
showMenu(createTileMenu(el, username, getHideActivities(), true));
});
// ---- Messaging ----
addCss(`
/* message list truncation */
#messenger div[class^="TruncateBlock__Content-sc-"]
{
-webkit-line-clamp: var(--message-line-clamp);
}
`);
onDom(`.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);
});
onMenu(".js-chat .reactView", el =>
{
// messages > message
const a = el.querySelector(`a[href^="/profile/"]`);
const username = getUsernameFromHref(a.href);
showMenu([
...createTileMenu(el, username, getHideMessages(), false),
menuItem("trashcan", "deleteUnread", () =>
{
const a = el.querySelector(`a[href^="/messenger/chat/"]`);
const sep = a.href.lastIndexOf("/");
const id = parseInt(a.href.substring(sep + 1));
sendXhr("DELETE", `/api/v4/messages/conversations/${id}`);
el.remove();
})
]);
});
onMenu(".js-chat .reactView img", el =>
{
// messages > message > sent image
showMenu([
menuItem("search", "viewFullImage", () => showImagePreview(getImageUrl(el.src))),
]);
});
onMenu(".js-contacts .reactView", el =>
{
// contacts > contact
const a = el.querySelector(`a[href^="/profile/"]`);
const username = getUsernameFromHref(a.href);
showMenu(createTileMenu(el, username, getHideContacts(), false));
});
// ---- Albums ----
let changeProfilePic = false;
onDom(`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 ----
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");
if (desc)
{
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 filter section.
const filterSection = addSection("filter");
const enhancedFilter = addCheckbox(filterSection, "enhancedFilter", "enhancedFilterDesc");
enhancedFilter.checked = getEnhancedFilter();
enhancedFilter.addEventListener("change", e => setEnhancedFilter(e.target.checked));
const discoverFilter = addCheckbox(filterSection, "discoverFilter", "discoverFilterDesc");
discoverFilter.checked = getDiscoverFilter();
discoverFilter.addEventListener("change", e => setDiscoverFilter(e.target.checked));
const searchFilter = addCheckbox(filterSection, "searchFilter", "searchFilterDesc");
searchFilter.checked = getSearchFilter();
searchFilter.addEventListener("change", e => setSearchFilter(e.target.checked));
// Add tiles section.
const tilesSection = addSection("tiles");
const enhancedTiles = addCheckbox(tilesSection, "enhancedTiles", "enhancedTilesDesc");
enhancedTiles.checked = getEnhancedTiles();
enhancedTiles.addEventListener("change", e => setEnhancedTiles(e.target.checked));
const enhancedImages = addCheckbox(tilesSection, "enhancedImages", "enhancedImagesDesc");
enhancedImages.checked = getEnhancedImages();
enhancedImages.addEventListener("change", e => setEnhancedImages(e.target.checked));
const fullHeadlines = addCheckbox(tilesSection, "fullHeadlines", "fullHeadlinesDesc");
fullHeadlines.checked = getFullHeadlines();
fullHeadlines.addEventListener("change", e => setFullHeadlines(e.target.checked));
const tileCount = addNumber(tilesSection, "tileCount", 0, 10);
tileCount.value = getTileCount();
tileCount.addEventListener("change", e => setTileCount(parseInt(e.target.value)));
const tileDetailsList = addTagList(tilesSection, "tileDetailsList");
for (const tileDetail of ["age", "height", "weight", "bmi", "smoker", "ageRange",
"bodyHair", "bodyType", "ethnicity", "relationship", "analPosition",
"dick", "saferSex", "dirty", "sm", "fisting", "openTo"])
{
addTag(tileDetailsList, tileDetail, translate(tileDetail), tileDetails.has(tileDetail), e => setTileDetail(e.tag, e.checked));
}
// Add messages section.
const messagesSection = addSection("messages");
const systemMessages = addCheckbox(messagesSection, "systemMessages", "systemMessagesDesc");
systemMessages.checked = getSystemMessages();
systemMessages.addEventListener("change", e => setSystemMessages(e.target.checked));
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 hideMessages = addCheckbox(hiddenUsersSection, "hideMessages");
hideMessages.checked = getHideMessages();
hideMessages.addEventListener("change", e => setHideMessages(e.target.checked));
const hideContacts = addCheckbox(hiddenUsersSection, "hideContacts");
hideContacts.checked = getHideContacts();
hideContacts.addEventListener("change", e => setHideContacts(e.target.checked));
const hideVisits = addCheckbox(hiddenUsersSection, "hideVisits");
hideVisits.checked = getHideVisits();
hideVisits.addEventListener("change", e => setHideVisits(e.target.checked));
const hideLikes = addCheckbox(hiddenUsersSection, "hideLikes");
hideLikes.checked = getHideLikes();
hideLikes.addEventListener("change", e => setHideLikes(e.target.checked));
const hideFriends = addCheckbox(hiddenUsersSection, "hideFriends");
hideFriends.checked = getHideFriends();
hideFriends.addEventListener("change", e => setHideFriends(e.target.checked));
const hideActivities = addCheckbox(hiddenUsersSection, "hideActivities");
hideActivities.checked = getHideActivities();
hideActivities.addEventListener("change", e => setHideActivities(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)
});
}
onDom(`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"));
});
onDom(`#offcanvas-nav > .js-layer-content > main > div.layout > div.reactView--autoHeight > p[class^="MiniText-sc-"]`, el =>
{
el.innerHTML += `<a class="marionette" style="display:block" href="${GM_info.script.downloadURL}" target="blank">${GM_info.script.name} ${GM_info.script.version}</a>`;
});
// ---- Load ----
setFullHeadlines(getFullHeadlines());
setFullMessages(getFullMessages());
setSystemMessages(getSystemMessages());
setTileCount(getTileCount());
tileDetails = getTileDetails();
initMenu();
initDom();
initPreviews();
initFetch();
initXhr();