Add friend avatars
This commit is contained in:
parent
71340b6b1d
commit
8ed38d05a7
18
index.css
18
index.css
@ -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 */
|
||||||
|
|||||||
59
index.js
59
index.js
@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user