c.stupid.cat/js/friends.js

453 lines
19 KiB
JavaScript

(function friends() {
"use strict";
var FRIENDS = [
{
title: "Fiancée",
members: [
{ name: "Aria", img: "/assets/friends/ari.png", tier: "wife", discordId: "1305215902685597797", link: null }
]
},
{
title: "Close Friends",
members: [
{ name: "Lilly", img: "/assets/friends/lilly.png", tier: "close", discordId: "908055723659898902", link: null },
{ name: "Ria", img: "/assets/friends/ria.png", tier: "close", discordId: "1513506390088618145", link: null },
{ name: "Camilla", img: "/assets/friends/camilla.png", tier: "close", discordId: "1110542429838397471", link: "https://cammy-the-cat.com" },
{ name: "Saphie", img: "/assets/friends/saphie.png", tier: "close", discordId: "527709099186716673", link: null },
{ name: "Ari", img: "/assets/friends/meowhem.png", tier: "close", discordId: "1474568910736199825", link: "https://a.stupid.cat" }
]
},
{
title: "Friends",
members: [
{ name: "Aureal", img: "/assets/friends/aureal.gif", tier: "friend", discordId: "1498977251134279900", link: "https://aureal.dev/" },
{ name: "Fin", img: "/assets/friends/fin.png", tier: "friend", discordId: "867818211574808607", link: null },
{ name: "Meme", img: "/assets/friends/meme.png", tier: "friend", discordId: "812998699667161098", link: null },
{ name: "N", img: "/assets/friends/n.png", tier: "friend", discordId: "639399972407869450", link: null },
{ name: "Lylla", img: "/assets/friends/lylla.png", tier: "friend", discordId: "1009889543878611016", link: null },
{ name: "Simon", img: "/assets/friends/simon.png", tier: "friend", discordId: "758466783354814514", link: null }
]
},
{
title: "Other Peeps",
subtitle: "You can request to be added here!",
members: [
{ name: "furi", img: "/assets/known-people/furi.png", tier: "known", discordId: "781445370177126401", link: "https://furina.is-a.dev"},
{ name: "pokemon", img: "/assets/known-people/pokemon.png", tier: "known", discordId: "784443338627612673", link: "https://devmatei.com/" },
{ name: "Sy", img: "/assets/known-people/sy.png", tier: "known", discordId: "1354212422839308469", link: "https://synapsexen.xyz/"}
]
},
{
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);
}
// 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" 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>';
return {
el: card,
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");
}
// --- 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";
}
return "profile";
})
.catch(function () { return false; });
}
// ---- live data: Lanyard (status + avatar) ---------------------------
function loadLanyard(m, refs) {
return fetch("https://api.lanyard.rest/v1/users/" + m.discordId)
.then(function (r) { return r.json().catch(function () { return null; }); })
.then(function (j) {
if (!j || !j.success || !j.data) {
// in config but not in the Lanyard server → stays "unconnected"
return false;
}
var d = j.data;
var u = d.discord_user || {};
var status = d.discord_status || "offline";
refs.el.dataset.status = status;
refs.statusDot.title = STATUS_TITLE[status] || "Offline";
if (u.avatar) refs.pfp.src = avatarUrl(u);
// avatar decoration (animated APNG) — loaded straight from Discord's
// CDN; the wsrv webp proxy fails on these and would drop the animation.
var deco = u.avatar_decoration_data;
if (refs.deco) {
if (deco && deco.asset) {
refs.deco.src = "https://cdn.discordapp.com/avatar-decoration-presets/" + deco.asset + ".png";
refs.deco.hidden = false;
} else {
refs.deco.hidden = true;
}
}
if (u.username) refs.user.textContent = "@" + u.username;
renderCustomStatus(refs, d.activities);
renderClanTag(refs, u.primary_guild);
return true;
})
.catch(function () {
// API down / network error → static fallback, blue dot
return false;
});
}
// ---- live data: dstn.to (badges + Nitro banner) ---------------------
function loadDstn(m, refs) {
return fetch("https://dcdn.dstn.to/profile/" + m.discordId)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) {
if (!j) return;
var u = j.user || {};
var prof = j.user_profile || {};
if (Array.isArray(j.badges) && j.badges.length) {
refs.badges.innerHTML = renderDstnBadges(j.badges);
}
var bUrl = bannerUrl(u.id || m.discordId, prof.banner || u.banner);
if (bUrl) {
refs.banner.style.backgroundImage = "url('" + bUrl + "')";
refs.el.classList.add("has-banner");
} else if (u.banner_color) {
refs.banner.style.background = u.banner_color;
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");
}
})
.catch(function () {});
}
// Self-hosted API is the primary source; Lanyard + dstn.to are fallbacks.
// Resolves to true if ANY source had data for this member, false if they
// were not found anywhere — callers use that to stop re-polling 404s.
function refreshMember(m, refs) {
return loadSelfHosted(m, refs).then(function (result) {
if (result === "full") return true; // self-host covered everything
if (result === "profile") {
// profile came from self-host but it isn't tracking live presence —
// pull the live status dot + activity/custom status from Lanyard only.
loadLanyard(m, refs);
return true;
}
// result === false → self-host had nothing: original Lanyard + dstn path
return loadLanyard(m, refs).then(function (live) {
if (live) { loadDstn(m, refs); return true; }
return false; // not found in self-host OR Lanyard
});
});
}
// 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);
}
})();