c.stupid.cat/js/friends.js

449 lines
19 KiB
JavaScript

(function friends() {
"use strict";
var FRIENDS = [
{
title: "Fiancée",
members: [
{ name: "Aria", tier: "wife", discordId: "1305215902685597797", link: null }
]
},
{
title: "Close Friends",
members: [
{ name: "Lilly", tier: "close", discordId: "908055723659898902", link: null },
{ name: "Ria", tier: "close", discordId: "1513506390088618145", link: null },
{ name: "Camilla", tier: "close", discordId: "1110542429838397471", link: "https://cammy-the-cat.com" },
{ name: "Saphie", tier: "close", discordId: "527709099186716673", link: null },
{ name: "Ari", tier: "close", discordId: "1474568910736199825", link: "https://a.stupid.cat" }
]
},
{
title: "Friends",
members: [
{ name: "Fin", tier: "friend", discordId: "867818211574808607", link: null },
{ name: "Meme", tier: "friend", discordId: "812998699667161098", link: null },
{ name: "N", tier: "friend", discordId: "639399972407869450", link: null },
{ name: "Lylla", tier: "friend", discordId: "1009889543878611016", link: null },
{ name: "Simon", tier: "friend", discordId: "758466783354814514", link: null }
]
},
{
title: "Other Peeps",
subtitle: "You can request to be added here!",
members: [
{ name: "furi", tier: "known", discordId: "781445370177126401", link: "https://furina.is-a.dev"},
{ name: "pokemon", tier: "known", discordId: "784443338627612673", link: "https://devmatei.com/" }
]
},
{
title: "Alts",
subtitle: "My other accounts, dead or alive",
members: [
{ name: "J", img: "/assets/alts/j.png", tier: "active-alt", discordId: "1500197577336033301", link: null},
{ name: "Uzi", img: "/assets/alts/uzi.png", tier: "active-alt", discordId: "526626867973849123", link: null },
{ name: "clovetwilight3", img: "/assets/alts/clovetwilight3.png", tier: "dead-alt", discordId: null, link: null },
{ name: "estrogenhrt", img: "/assets/alts/estrogenhrt.png", tier: "dead-alt", discordId: null, link: null },
{ name: "Clove <3", img: "/assets/alts/clove.png", tier: "dead-alt", discordId: "1125844710511104030", link: null},
{ name: "Clove ⛤", img: "/assets/alts/butterfly.png", tier: "dead-alt", discordId: "514994021970739201", link: null },
{ name: "Mrow", img: "/assets/alts/mrow.png", tier: "dead-alt", discordId: "219480349053288450", link: null }
]
}
];
var REFRESH_MS = 60000; // re-poll live friends once a minute
var root = document.getElementById("friends-root");
if (!root) return;
// title → URL-safe anchor id, e.g. "Close Friends" -> "close-friends"
function slugify(str) {
return String(str == null ? "" : str)
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
// ---- helpers (mirrors discord.js) -----------------------------------
function esc(str) {
return String(str == null ? "" : str)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function intToHex(n) {
return "#" + (Number(n) >>> 0).toString(16).padStart(6, "0").slice(-6);
}
function rgbTriplet(n) {
n = Number(n) >>> 0;
return ((n >> 16) & 255) + ", " + ((n >> 8) & 255) + ", " + (n & 255);
}
// Nitro profile gradient -> card background (mirrors the /discord card)
function applyGrad(refs, colors) {
if (!colors || colors.length < 2) return;
refs.el.style.setProperty("--fc-grad-1-rgb", rgbTriplet(colors[0]));
refs.el.style.setProperty("--fc-grad-2-rgb", rgbTriplet(colors[1]));
refs.el.classList.add("has-profile-grad");
}
// Nitro "display name styles" -> gradient on the name text (same effect the
// /discord card uses). Lanyard + dstn.to expose this; our API does not yet.
// Discord's 8 display-name fonts -> closest free Google Fonts (approximations;
// Discord's own faces are proprietary). font_id 1 = gg sans = our default.
var FONT_BY_ID = {
2: '"Poppins", sans-serif', // Tempo
3: '"Klee One", cursive', // Sakura
4: '"Baloo 2", cursive', // Jellybean
5: '"Montserrat", sans-serif', // Modern
6: '"MedievalSharp", cursive', // Medieval
7: '"Press Start 2P", monospace', // 8Bit
8: '"Pirata One", system-ui' // Vampyre
};
var GFONTS_HREF = "https://fonts.googleapis.com/css2?" +
"family=Baloo+2:wght@600&family=Klee+One:wght@600&family=MedievalSharp&" +
"family=Montserrat:wght@700&family=Pirata+One&family=Poppins:wght@600&" +
"family=Press+Start+2P&display=swap";
var _fontsInjected = false;
function ensureFonts() {
if (_fontsInjected) return;
_fontsInjected = true;
var l = document.createElement("link");
l.rel = "stylesheet"; l.href = GFONTS_HREF;
document.head.appendChild(l);
}
function applyNameStyle(refs, styles) {
if (!refs.name || !styles) return;
// gradient / colour
if (styles.colors && styles.colors.length) {
var cols = styles.colors.map(intToHex);
refs.name.style.backgroundImage = "linear-gradient(90deg, " +
(cols.length === 1 ? cols[0] + "," + cols[0] : cols.join(", ")) + ")";
refs.name.classList.add("is-gradient");
}
// font face (lazy-load the Google Fonts sheet only when one is used)
var fam = styles.font_id && FONT_BY_ID[styles.font_id];
if (fam) { ensureFonts(); refs.name.style.fontFamily = fam; }
}
// Re-serve Discord CDN images cookieless via wsrv.nl (same trick as the
// presence card) so no third-party cookies are set.
function proxyImg(url, opts) {
if (!url) return url;
if (!/^https:\/\/(cdn|media)\.discordapp\.(com|net)\//.test(url)) return url;
var src = url.replace(/^https:\/\//, "");
var q = "https://wsrv.nl/?url=" + encodeURIComponent(src) + "&output=webp";
if (opts && opts.w) q += "&w=" + opts.w + "&dpr=2&fit=cover";
return q;
}
function avatarUrl(u) {
if (!u || !u.avatar) {
var def = (Number(u && u.discriminator) || 0) % 5;
return proxyImg("https://cdn.discordapp.com/embed/avatars/" + def + ".png");
}
var ext = String(u.avatar).startsWith("a_") ? "gif" : "png";
return proxyImg("https://cdn.discordapp.com/avatars/" + u.id + "/" + u.avatar + "." + ext + "?size=128", { w: 96 });
}
function bannerUrl(id, hash) {
if (!id || !hash) return null;
// Animated banners (a_) must be requested WITHOUT the .gif extension:
// Discord's CDN throws HTTP 415 for some a_*.gif banners, but the
// extension-less URL works (wsrv then re-serves it as cookieless webp).
var animated = String(hash).startsWith("a_");
var url = "https://cdn.discordapp.com/banners/" + id + "/" + hash + (animated ? "" : ".png") + "?size=480";
return proxyImg(url, { w: 480 });
}
// custom-status emoji → CDN url (custom emoji) or null (unicode emoji)
function customEmojiUrl(e) {
if (!e || !e.id) return null;
return proxyImg("https://cdn.discordapp.com/emojis/" + e.id + (e.animated ? ".gif" : ".png") + "?size=32");
}
// Render the Discord custom status (activity type 4): emoji + text.
function renderCustomStatus(refs, activities) {
if (!refs.custom) return;
var c = (activities || []).find(function (a) { return a && a.type === 4; });
var text = c && c.state ? String(c.state) : "";
var emoji = c && c.emoji;
if (!text && !(emoji && (emoji.id || emoji.name))) {
refs.custom.hidden = true;
refs.custom.innerHTML = "";
return;
}
var eu = emoji ? customEmojiUrl(emoji) : null;
var emojiHtml = eu
? '<img class="fc-custom-emoji" src="' + eu + '" alt="" onerror="this.remove()">'
: (emoji && emoji.name ? '<span class="fc-custom-emoji-uni">' + esc(emoji.name) + "</span>" : "");
refs.custom.innerHTML = emojiHtml + (text ? '<span class="fc-custom-text">' + esc(text) + "</span>" : "");
refs.custom.hidden = false;
}
// dstn.to badge list → small icon row (same source the presence card uses)
function renderDstnBadges(badges) {
if (!Array.isArray(badges)) return "";
return badges.map(function (b) {
var img = '<img class="fc-badge" src="' +
proxyImg("https://cdn.discordapp.com/badge-icons/" + esc(b.icon) + ".png") +
'" alt="' + esc(b.description || b.id) + '" title="' + esc(b.description || b.id) +
'" onerror="this.remove()">';
return b.link
? '<a class="fc-badge-link" href="' + esc(b.link) + '" target="_blank" rel="noopener">' + img + "</a>"
: img;
}).join("");
}
// Self-hosted badge list → small icon row. The self-hosted API ships a
// ready-made `icon_url`; fall back to building it from `icon` if absent.
function renderSelfBadges(badges) {
if (!Array.isArray(badges)) return "";
return badges.map(function (b) {
var url = b.icon_url || ("https://cdn.discordapp.com/badge-icons/" + esc(b.icon) + ".png");
var img = '<img class="fc-badge" src="' + proxyImg(url) +
'" alt="' + esc(b.description || b.id) + '" title="' + esc(b.description || b.id) +
'" onerror="this.remove()">';
return b.link
? '<a class="fc-badge-link" href="' + esc(b.link) + '" target="_blank" rel="noopener">' + img + "</a>"
: img;
}).join("");
}
// Self-hosted clan tag → guild badge + tag. Shape: {tag, badge_url}.
function renderSelfClan(refs, clan) {
if (!refs.tag) return;
if (clan && clan.tag) {
var b = clan.badge_url ? proxyImg(clan.badge_url) : null;
refs.tag.innerHTML = (b ? '<img class="fc-tag-badge" src="' + b + '" alt="" onerror="this.remove()">' : "") +
'<span class="fc-tag-text">' + esc(clan.tag) + "</span>";
refs.tag.hidden = false;
} else {
refs.tag.hidden = true;
}
}
// Discord server (clan) tag — the little guild badge + tag next to a name
function guildTagBadgeUrl(pg) {
if (!pg || !pg.badge || !pg.identity_guild_id) return null;
return proxyImg("https://cdn.discordapp.com/guild-tag-badges/" + pg.identity_guild_id + "/" + pg.badge + ".png?size=24");
}
function renderClanTag(refs, pg) {
if (!refs.tag) return;
if (pg && pg.tag && pg.identity_enabled) {
var b = guildTagBadgeUrl(pg);
refs.tag.innerHTML = (b ? '<img class="fc-tag-badge" src="' + b + '" alt="" onerror="this.remove()">' : "") +
'<span class="fc-tag-text">' + esc(pg.tag) + "</span>";
refs.tag.hidden = false;
} else {
refs.tag.hidden = true;
}
}
// ---- build one card -------------------------------------------------
function buildCard(m) {
var card = document.createElement("article");
card.className = "friend-card" + (m.tier ? " tier-" + m.tier : "");
card.dataset.status = "unconnected"; // until proven otherwise
var nameTag = m.link ? "a" : "span";
var nameAttrs = m.link ? ' href="' + esc(m.link) + '" target="_blank" rel="noopener"' : "";
card.innerHTML =
'<div class="fc-banner"></div>' +
'<div class="fc-main">' +
'<span class="fc-avatar">' +
'<img class="fc-pfp"' + (m.img ? ' src="' + esc(m.img) + '"' : '') + ' alt="' + esc(m.name) + '" loading="eager">' +
'<img class="fc-deco" alt="" aria-hidden="true" hidden>' +
'<span class="fc-status" title="not connected to Lanyard"></span>' +
'</span>' +
'<span class="fc-id">' +
'<span class="fc-name-row">' +
'<' + nameTag + ' class="fc-name' + (m.tier ? " " + m.tier : "") + '"' + nameAttrs + '>' + esc(m.name) + '</' + nameTag + '>' +
'<span class="fc-tag" hidden></span>' +
'</span>' +
'<span class="fc-user"></span>' +
'<span class="fc-custom" hidden></span>' +
'<span class="fc-badges"></span>' +
'</span>' +
'</div>';
// Hover expansion: pin the card's current height BEFORE adding
// .is-hovering (which lifts .fc-main out of flow). Pinning keeps the
// grid slot a fixed size so the page never reflows -> no hover ping-pong.
card.addEventListener("mouseenter", function () {
card.style.height = card.offsetHeight + "px";
card.classList.add("is-hovering");
});
card.addEventListener("mouseleave", function () {
card.classList.remove("is-hovering");
card.style.height = "";
});
return {
el: card,
name: card.querySelector(".fc-name"),
pfp: card.querySelector(".fc-pfp"),
deco: card.querySelector(".fc-deco"),
statusDot: card.querySelector(".fc-status"),
user: card.querySelector(".fc-user"),
custom: card.querySelector(".fc-custom"),
tag: card.querySelector(".fc-tag"),
badges: card.querySelector(".fc-badges"),
banner: card.querySelector(".fc-banner")
};
}
var STATUS_TITLE = {
online: "Online",
idle: "Idle",
dnd: "Do Not Disturb",
offline: "Offline"
};
// ---- live data: self-hosted API (primary source) -------------------
// Returns a promise resolving to:
// "full" → user + live presence rendered (no fallback needed)
// "profile" → user rendered but presence was null (fall back to Lanyard
// for the live status dot + current activity/custom status)
// false → nothing usable (full Lanyard + dstn fallback)
var SELF_BASE = "https://restful.doughmination.uk/v1/users/";
function loadSelfHosted(m, refs) {
return fetch(SELF_BASE + m.discordId, { cache: "no-store" })
.then(function (r) { return r.ok ? r.json().catch(function () { return null; }) : null; })
.then(function (j) {
if (!j || !j.success || !j.data || !j.data.user) return false;
var u = j.data.user || {};
var p = j.data.presence; // may be null when the user isn't live-tracked
var havePresence = !!p;
// --- profile (rendered whenever we have a user object) ---
if (u.avatar) {
refs.pfp.src = avatarUrl({ id: u.id || m.discordId, avatar: u.avatar });
}
// avatar decoration — load straight from Discord's CDN (the wsrv webp
// proxy drops the APNG animation). API ships a ready `url`.
if (refs.deco) {
var deco = u.avatar_decoration;
var dUrl = deco && (deco.url ||
(deco.asset ? "https://cdn.discordapp.com/avatar-decoration-presets/" + deco.asset + ".png" : null));
if (dUrl) { refs.deco.src = dUrl; refs.deco.hidden = false; }
else { refs.deco.hidden = true; }
}
if (u.username) refs.user.textContent = "@" + u.username;
renderSelfClan(refs, u.clan);
if (Array.isArray(j.data.badges) && j.data.badges.length) {
refs.badges.innerHTML = renderSelfBadges(j.data.badges);
}
// banner — rebuild from the raw hash via bannerUrl() rather than the
// API's banner_url, which uses a `.gif` extension that 415s for some
// animated banners. Falls back to accent colour.
var bUrl = bannerUrl(u.id || m.discordId, u.banner);
if (bUrl) {
refs.banner.style.backgroundImage = "url('" + bUrl + "')";
refs.el.classList.add("has-banner");
} else if (typeof u.accent_color === "number") {
refs.banner.style.background = intToHex(u.accent_color);
refs.el.classList.add("has-banner");
}
if (Array.isArray(u.theme_colors)) applyGrad(refs, u.theme_colors);
applyNameStyle(refs, u.display_name_styles);
// --- live presence ---
if (havePresence) {
var status = p.status || (p.online ? "online" : "offline");
refs.el.dataset.status = status;
refs.statusDot.title = STATUS_TITLE[status] || "Offline";
// activities follow the standard Discord shape (type 4 = custom status)
renderCustomStatus(refs, p.activities);
return "full";
}
// User EXISTS in our API but we have no live presence for them (they're
// offline, or not in a guild our gateway tracks). Treat "exists" as
// offline rather than the default blue "unconnected". Only accounts the
// API can't find at all keep the blue dot.
refs.el.dataset.status = "offline";
refs.statusDot.title = "Offline";
return "profile";
})
.catch(function () { return false; });
}
// ---- resolve a member: Doughmination Restful API ONLY ---------------
// Lanyard + dstn.to were removed. Everything (presence, profile, badges,
// banner, Nitro gradient, display-name styles) now comes from
// restful.doughmination.uk in a single call. Resolves true if the API had
// the user, false if not found (caller stops polling after a few misses).
function refreshMember(m, refs) {
return loadSelfHosted(m, refs).then(function (result) {
return result !== false;
});
}
// Stop polling a member after this many consecutive "not found anywhere"
// results. Keeps 404s (e.g. someone not in the self-host DB or Lanyard)
// from spamming the console on every refresh. Any successful resolve resets
// the counter, so a transient blip won't permanently drop a friend. Tunable.
var GIVE_UP_AFTER = 3;
function pollEntry(entry) {
if (entry.stop) return;
refreshMember(entry.m, entry.refs).then(function (resolved) {
if (resolved) { entry.misses = 0; return; }
entry.misses++;
if (entry.misses >= GIVE_UP_AFTER) entry.stop = true; // give up for this session
});
}
// ---- render ---------------------------------------------------------
var liveMembers = []; // {m, refs} for those with an id, for polling
FRIENDS.forEach(function (group) {
var section = document.createElement("section");
section.className = "section";
section.id = slugify(group.title); // anchor target, e.g. #alts
var h2 = document.createElement("h2");
h2.className = "section-title";
h2.textContent = group.title;
section.appendChild(h2);
if (group.subtitle) {
var sub = document.createElement("p");
sub.className = "section-subtitle";
sub.textContent = group.subtitle;
section.appendChild(sub);
}
var grid = document.createElement("div");
grid.className = "friend-grid";
group.members.forEach(function (m) {
var refs = buildCard(m);
grid.appendChild(refs.el);
// poll anyone with an ID — even "dead" alts can be semi-alive. Accounts
// that 404 everywhere are dropped automatically by the give-up logic.
if (m.discordId) {
var entry = { m: m, refs: refs, misses: 0, stop: false };
liveMembers.push(entry);
pollEntry(entry);
}
});
section.appendChild(grid);
root.appendChild(section);
});
// ---- jump to anchor (sections are built after page load) ------------
function scrollToHash() {
var id = (location.hash || "").slice(1);
if (!id) return;
var target = document.getElementById(id);
if (target) target.scrollIntoView();
}
scrollToHash();
window.addEventListener("hashchange", scrollToHash);
// ---- poll live members ----------------------------------------------
if (liveMembers.length) {
setInterval(function () {
if (document.hidden) return;
liveMembers.forEach(function (x) { if (!x.stop) pollEntry(x); });
}, REFRESH_MS);
}
})();