Add friend avatars

This commit is contained in:
pleb 2026-04-11 18:11:25 -07:00
parent 71340b6b1d
commit 8ed38d05a7
5 changed files with 116 additions and 40 deletions

View File

@ -344,12 +344,26 @@ span:empty {
.friend-score-item { .friend-score-item {
display: flex; display: flex;
align-items: baseline; align-items: center;
gap: 0.7rem; gap: 0.55rem;
}
.friend-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.friend-name {
flex: 1;
min-width: 0;
} }
.friend-acc { .friend-acc {
font-weight: 700; font-weight: 700;
flex-shrink: 0;
} }
/* Settings */ /* Settings */

View File

@ -118,23 +118,26 @@ async function fetchAllFollowers(playerId, type2, maxPages = 100) {
} }
return all; return all;
} }
async function fetchFriendIds(playerId, mode, maxPages = 100) { function normalizeFollowerEntry(entry) {
return {
...entry,
id: String(entry.id)
};
}
async function fetchFriends(playerId, mode, maxPages = 100) {
const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId); const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId);
const [following, followers] = await Promise.all([ const [following, followers] = await Promise.all([
fetchAllFollowers(canonicalPlayerId, "Following", maxPages), fetchAllFollowers(canonicalPlayerId, "Following", maxPages),
fetchAllFollowers(canonicalPlayerId, "Followers", maxPages) fetchAllFollowers(canonicalPlayerId, "Followers", maxPages)
]); ]);
const followingIds = new Set(following.map((entry) => String(entry.id))); const followingIds = new Set(following.map((entry) => String(entry.id)));
const followerIds = new Set(followers.map((entry) => String(entry.id))); if (mode === "following") {
if (mode === "following") return followingIds; return following.map((entry) => normalizeFollowerEntry(entry));
if (mode === "followers") return followerIds;
const mutuals = /* @__PURE__ */ new Set();
for (const id of followerIds) {
if (followingIds.has(id)) {
mutuals.add(id);
}
} }
return mutuals; if (mode === "followers") {
return followers.map((entry) => normalizeFollowerEntry(entry));
}
return followers.filter((entry) => followingIds.has(String(entry.id))).map((entry) => normalizeFollowerEntry(entry));
} }
function normalizeAccuracy(value) { function normalizeAccuracy(value) {
if (typeof value !== "number" || !Number.isFinite(value)) return null; if (typeof value !== "number" || !Number.isFinite(value)) return null;
@ -289,6 +292,13 @@ function updateScore(score) {
mistakes.textContent = score.missCount ? String(score.missCount) : ""; mistakes.textContent = score.missCount ? String(score.missCount) : "";
accuracy.classList.toggle("failed", score.currentHealth === 0); accuracy.classList.toggle("failed", score.currentHealth === 0);
} }
function avatarFromScore(score) {
if (typeof score.player === "object" && score.player?.avatar) {
return score.player.avatar;
}
const url = score.playerAvatar?.trim();
return url || null;
}
function clearFriendScores(message) { function clearFriendScores(message) {
friendScoresList.replaceChildren(); friendScoresList.replaceChildren();
friendScoresEmpty.textContent = message; friendScoresEmpty.textContent = message;
@ -306,13 +316,19 @@ function renderFriendScores(items) {
for (const item of items) { for (const item of items) {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "friend-score-item"; li.className = "friend-score-item";
const avatar = document.createElement("img");
avatar.className = "friend-avatar";
avatar.alt = "";
avatar.decoding = "async";
avatar.loading = "lazy";
avatar.src = item.avatar?.trim() || "images/unknown.svg";
const name = document.createElement("span"); const name = document.createElement("span");
name.className = "friend-name"; name.className = "friend-name";
name.textContent = item.name; name.textContent = item.name;
const acc = document.createElement("span"); const acc = document.createElement("span");
acc.className = "friend-acc"; acc.className = "friend-acc";
acc.textContent = `${item.acc.toFixed(2)}%`; acc.textContent = `${item.acc.toFixed(2)}%`;
li.append(name, acc); li.append(acc, avatar, name);
friendScoresList.appendChild(li); friendScoresList.appendChild(li);
} }
} }
@ -335,15 +351,20 @@ async function refreshMapFriendScores() {
friendScoresEmpty.textContent = "Loading mutual friend scores..."; friendScoresEmpty.textContent = "Loading mutual friend scores...";
const requestId = ++friendScoreRequestId; const requestId = ++friendScoreRequestId;
try { try {
const [leaderboards, mutualFriendIds] = await Promise.all([ const [leaderboards, friends] = await Promise.all([
fetchBLLeaderboardsByHash(hash), fetchBLLeaderboardsByHash(hash),
fetchFriendIds(playerId, settings.friendMode) fetchFriends(playerId, settings.friendMode)
]); ]);
if (requestId !== friendScoreRequestId) return; if (requestId !== friendScoreRequestId) return;
if (leaderboards.length === 0) { if (leaderboards.length === 0) {
clearFriendScores("No BeatLeader leaderboards found"); clearFriendScores("No BeatLeader leaderboards found");
return; return;
} }
const friendById = new Map(friends.map((f) => [
f.id,
f
]));
const mutualFriendIds = new Set(friends.map((f) => f.id));
if (mutualFriendIds.size === 0) { if (mutualFriendIds.size === 0) {
const relationLabel = settings.friendMode === "following" ? "No followed BeatLeader players" : settings.friendMode === "followers" ? "No BeatLeader followers" : "No mutual BeatLeader followers"; const relationLabel = settings.friendMode === "following" ? "No followed BeatLeader players" : settings.friendMode === "followers" ? "No BeatLeader followers" : "No mutual BeatLeader followers";
clearFriendScores(relationLabel); clearFriendScores(relationLabel);
@ -353,17 +374,21 @@ async function refreshMapFriendScores() {
if (requestId !== friendScoreRequestId) return; if (requestId !== friendScoreRequestId) return;
const bestByPlayer = /* @__PURE__ */ new Map(); const bestByPlayer = /* @__PURE__ */ new Map();
for (const score of scores) { for (const score of scores) {
const playerId2 = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null); const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null);
const playerKey = playerId2 == null ? "" : String(playerId2); const playerKey = scorePlayerId == null ? "" : String(scorePlayerId);
if (!playerKey || !mutualFriendIds.has(playerKey)) continue; if (!playerKey || !mutualFriendIds.has(playerKey)) continue;
const acc = normalizeAccuracy(score.accuracy ?? score.acc); const acc = normalizeAccuracy(score.accuracy ?? score.acc);
if (acc === null) continue; if (acc === null) continue;
const existing = bestByPlayer.get(playerKey); const existing = bestByPlayer.get(playerKey);
if (!existing || acc > existing.acc) { if (!existing || acc > existing.acc) {
const friendMeta = friendById.get(playerKey);
const playerName = score.playerName || (typeof score.player === "object" ? score.player?.name : typeof score.player === "string" ? score.player : null); const playerName = score.playerName || (typeof score.player === "object" ? score.player?.name : typeof score.player === "string" ? score.player : null);
const fromScore = avatarFromScore(score);
const fromFriend = friendMeta?.avatar?.trim() || null;
bestByPlayer.set(playerKey, { bestByPlayer.set(playerKey, {
name: playerName || playerKey, name: playerName || friendMeta?.name || playerKey,
acc acc,
avatar: fromScore ?? fromFriend
}); });
} }
} }

View File

@ -166,6 +166,32 @@ export async function fetchMutualFriendIds(playerId: string, maxPages = 100): Pr
return fetchFriendIds(playerId, "mutual", maxPages); return fetchFriendIds(playerId, "mutual", maxPages);
} }
function normalizeFollowerEntry(entry: BeatLeaderFollower): BeatLeaderFollower {
return {
...entry,
id: String(entry.id),
};
}
/** Friend list for the given mode, with `avatar` / `name` from BeatLeader follower payloads. */
export async function fetchFriends(playerId: string, mode: FriendMode, maxPages = 100): Promise<BeatLeaderFollower[]> {
const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId);
const [following, followers] = await Promise.all([
fetchAllFollowers(canonicalPlayerId, "Following", maxPages),
fetchAllFollowers(canonicalPlayerId, "Followers", maxPages),
]);
const followingIds = new Set(following.map((entry) => String(entry.id)));
if (mode === "following") {
return following.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
}
if (mode === "followers") {
return followers.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
}
return followers
.filter((entry) => followingIds.has(String(entry.id)))
.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
}
export async function fetchFriendIds(playerId: string, mode: FriendMode, maxPages = 100): Promise<Set<string>> { export async function fetchFriendIds(playerId: string, mode: FriendMode, maxPages = 100): Promise<Set<string>> {
const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId); const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId);
const [following, followers] = await Promise.all([ const [following, followers] = await Promise.all([
@ -186,18 +212,7 @@ export async function fetchFriendIds(playerId: string, mode: FriendMode, maxPage
} }
export async function fetchMutualFriends(playerId: string, maxPages = 100): Promise<BeatLeaderFollower[]> { export async function fetchMutualFriends(playerId: string, maxPages = 100): Promise<BeatLeaderFollower[]> {
const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId); return fetchFriends(playerId, "mutual", maxPages);
const [following, followers] = await Promise.all([
fetchAllFollowers(canonicalPlayerId, "Following", maxPages),
fetchAllFollowers(canonicalPlayerId, "Followers", maxPages),
]);
const followingIds = new Set(following.map((entry) => String(entry.id)));
return followers
.filter((entry) => followingIds.has(String(entry.id)))
.map((entry) => ({
...entry,
id: String(entry.id),
}));
} }
export function normalizeAccuracy(value: number | null | undefined): number | null { export function normalizeAccuracy(value: number | null | undefined): number | null {

View File

@ -1,4 +1,5 @@
import type { import type {
BeatLeaderScore,
BeatSaberPlusEvent, BeatSaberPlusEvent,
ChatRequestEntry, ChatRequestEntry,
ChatRequestPayload, ChatRequestPayload,
@ -9,7 +10,7 @@ import { fetchBeatSaverMapById, fetchBeatSaverMeta } from "./beatsaver.ts";
import { import {
fetchAllMapScoresByHash, fetchAllMapScoresByHash,
fetchBLLeaderboardsByHash, fetchBLLeaderboardsByHash,
fetchFriendIds, fetchFriends,
type FriendMode, type FriendMode,
normalizeAccuracy, normalizeAccuracy,
} from "./beatleader.ts"; } from "./beatleader.ts";
@ -190,6 +191,14 @@ function updateScore(score: Score) {
accuracy.classList.toggle("failed", score.currentHealth === 0); accuracy.classList.toggle("failed", score.currentHealth === 0);
} }
function avatarFromScore(score: BeatLeaderScore): string | null {
if (typeof score.player === "object" && score.player?.avatar) {
return score.player.avatar;
}
const url = score.playerAvatar?.trim();
return url || null;
}
function clearFriendScores(message: string) { function clearFriendScores(message: string) {
friendScoresList.replaceChildren(); friendScoresList.replaceChildren();
friendScoresEmpty.textContent = message; friendScoresEmpty.textContent = message;
@ -198,7 +207,7 @@ function clearFriendScores(message: string) {
friendScoresPanel.classList.remove("has-items", "is-loading"); friendScoresPanel.classList.remove("has-items", "is-loading");
} }
function renderFriendScores(items: Array<{ name: string; acc: number }>) { function renderFriendScores(items: Array<{ name: string; acc: number; avatar: string | null }>) {
friendScoresList.replaceChildren(); friendScoresList.replaceChildren();
friendScoresPanel.classList.toggle("has-items", items.length > 0); friendScoresPanel.classList.toggle("has-items", items.length > 0);
friendScoresPanel.classList.remove("is-loading"); friendScoresPanel.classList.remove("is-loading");
@ -208,13 +217,19 @@ function renderFriendScores(items: Array<{ name: string; acc: number }>) {
for (const item of items) { for (const item of items) {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "friend-score-item"; li.className = "friend-score-item";
const avatar = document.createElement("img");
avatar.className = "friend-avatar";
avatar.alt = "";
avatar.decoding = "async";
avatar.loading = "lazy";
avatar.src = item.avatar?.trim() || "images/unknown.svg";
const name = document.createElement("span"); const name = document.createElement("span");
name.className = "friend-name"; name.className = "friend-name";
name.textContent = item.name; name.textContent = item.name;
const acc = document.createElement("span"); const acc = document.createElement("span");
acc.className = "friend-acc"; acc.className = "friend-acc";
acc.textContent = `${item.acc.toFixed(2)}%`; acc.textContent = `${item.acc.toFixed(2)}%`;
li.append(name, acc); li.append(acc, avatar, name);
friendScoresList.appendChild(li); friendScoresList.appendChild(li);
} }
} }
@ -238,15 +253,17 @@ async function refreshMapFriendScores() {
friendScoresEmpty.textContent = "Loading mutual friend scores..."; friendScoresEmpty.textContent = "Loading mutual friend scores...";
const requestId = ++friendScoreRequestId; const requestId = ++friendScoreRequestId;
try { try {
const [leaderboards, mutualFriendIds] = await Promise.all([ const [leaderboards, friends] = await Promise.all([
fetchBLLeaderboardsByHash(hash), fetchBLLeaderboardsByHash(hash),
fetchFriendIds(playerId, settings.friendMode), fetchFriends(playerId, settings.friendMode),
]); ]);
if (requestId !== friendScoreRequestId) return; if (requestId !== friendScoreRequestId) return;
if (leaderboards.length === 0) { if (leaderboards.length === 0) {
clearFriendScores("No BeatLeader leaderboards found"); clearFriendScores("No BeatLeader leaderboards found");
return; return;
} }
const friendById = new Map(friends.map((f) => [f.id, f]));
const mutualFriendIds = new Set(friends.map((f) => f.id));
if (mutualFriendIds.size === 0) { if (mutualFriendIds.size === 0) {
const relationLabel = settings.friendMode === "following" const relationLabel = settings.friendMode === "following"
? "No followed BeatLeader players" ? "No followed BeatLeader players"
@ -258,21 +275,25 @@ async function refreshMapFriendScores() {
} }
const scores = await fetchAllMapScoresByHash(hash, leaderboards); const scores = await fetchAllMapScoresByHash(hash, leaderboards);
if (requestId !== friendScoreRequestId) return; if (requestId !== friendScoreRequestId) return;
const bestByPlayer = new Map<string, { name: string; acc: number }>(); const bestByPlayer = new Map<string, { name: string; acc: number; avatar: string | null }>();
for (const score of scores) { for (const score of scores) {
const playerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null); const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null);
const playerKey = playerId == null ? "" : String(playerId); const playerKey = scorePlayerId == null ? "" : String(scorePlayerId);
if (!playerKey || !mutualFriendIds.has(playerKey)) continue; if (!playerKey || !mutualFriendIds.has(playerKey)) continue;
const acc = normalizeAccuracy(score.accuracy ?? score.acc); const acc = normalizeAccuracy(score.accuracy ?? score.acc);
if (acc === null) continue; if (acc === null) continue;
const existing = bestByPlayer.get(playerKey); const existing = bestByPlayer.get(playerKey);
if (!existing || acc > existing.acc) { if (!existing || acc > existing.acc) {
const friendMeta = friendById.get(playerKey);
const playerName = const playerName =
score.playerName || score.playerName ||
(typeof score.player === "object" ? score.player?.name : typeof score.player === "string" ? score.player : null); (typeof score.player === "object" ? score.player?.name : typeof score.player === "string" ? score.player : null);
const fromScore = avatarFromScore(score);
const fromFriend = friendMeta?.avatar?.trim() || null;
bestByPlayer.set(playerKey, { bestByPlayer.set(playerKey, {
name: playerName || playerKey, name: playerName || friendMeta?.name || playerKey,
acc, acc,
avatar: fromScore ?? fromFriend,
}); });
} }
} }

View File

@ -114,6 +114,7 @@ export interface BeatLeaderScore {
modifiedScore?: number | null; modifiedScore?: number | null;
playerId?: string | number | null; playerId?: string | number | null;
playerName?: string | null; playerName?: string | null;
playerAvatar?: string | null;
player?: BeatLeaderPlayer | string | null; player?: BeatLeaderPlayer | string | null;
} }