Engagement features
This commit is contained in:
@@ -12,6 +12,73 @@ let currentShareText = "";
|
||||
let selectedSection = null;
|
||||
let chatSignature = null;
|
||||
let chatLoading = false;
|
||||
const PRESENCE_KEY = "thebutton-share-presence";
|
||||
let sharePresence = localStorage.getItem(PRESENCE_KEY) === "true";
|
||||
let presenceTab = sessionStorage.getItem("thebutton-presence-tab");
|
||||
if (!presenceTab) {
|
||||
presenceTab = Array.from(crypto.getRandomValues(new Uint8Array(16)), (n) => n.toString(16).padStart(2, "0")).join("");
|
||||
sessionStorage.setItem("thebutton-presence-tab", presenceTab);
|
||||
}
|
||||
let presenceQueue = Promise.resolve();
|
||||
let lastPresenceUpdate = null;
|
||||
|
||||
function renderPresence(data) {
|
||||
const count = data.online_count;
|
||||
const countText = `${count.toLocaleString()} ${count === 1 ? "player" : "players"} watching`;
|
||||
if ($("presence-count").textContent !== countText) $("presence-count").textContent = countText;
|
||||
$("chat-presence-summary").textContent = data.chat_players.length
|
||||
? `${data.chat_players.length} ${data.chat_players.length === 1 ? "player" : "players"} in the lounge`
|
||||
: "No players sharing chat presence yet";
|
||||
const list = $("chat-presence-names");
|
||||
// Player names are text, never HTML.
|
||||
if (list.dataset.names !== JSON.stringify(data.chat_players)) {
|
||||
list.replaceChildren(...data.chat_players.map((name) => el("li", name)));
|
||||
list.dataset.names = JSON.stringify(data.chat_players);
|
||||
}
|
||||
}
|
||||
|
||||
function syncPresence() {
|
||||
presenceQueue = presenceQueue.then(async () => {
|
||||
const active = !!token && sharePresence && !document.hidden;
|
||||
const inChat = active && selectedSection === "chat-section";
|
||||
const changed = !lastPresenceUpdate || lastPresenceUpdate.token !== token
|
||||
|| lastPresenceUpdate.active !== active || lastPresenceUpdate.inChat !== inChat;
|
||||
const due = active && (!lastPresenceUpdate || performance.now() - lastPresenceUpdate.at >= 15000);
|
||||
if (token && (changed || due) && (active || lastPresenceUpdate?.active)) {
|
||||
const data = await api("POST", "/api/presence", { tab_id: presenceTab, active, in_chat: inChat });
|
||||
lastPresenceUpdate = { token, active, inChat, at: performance.now() };
|
||||
$("presence-error").textContent = "";
|
||||
renderPresence(data);
|
||||
} else if (!document.hidden) {
|
||||
renderPresence(await api("GET", "/api/presence"));
|
||||
}
|
||||
}).catch((error) => {
|
||||
$("presence-count").textContent = "Online status temporarily unavailable";
|
||||
if (token && sharePresence) $("presence-error").textContent = "Couldn't update online status. Retrying automatically.";
|
||||
if (error.status === 401) {
|
||||
token = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
lastPresenceUpdate = null;
|
||||
updateButton();
|
||||
}
|
||||
});
|
||||
return presenceQueue;
|
||||
}
|
||||
|
||||
$("presence-opt-in").addEventListener("change", () => {
|
||||
sharePresence = $("presence-opt-in").checked;
|
||||
localStorage.setItem(PRESENCE_KEY, String(sharePresence));
|
||||
syncPresence();
|
||||
});
|
||||
document.addEventListener("visibilitychange", syncPresence);
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === PRESENCE_KEY || event.key === TOKEN_KEY) {
|
||||
sharePresence = localStorage.getItem(PRESENCE_KEY) === "true";
|
||||
token = localStorage.getItem(TOKEN_KEY);
|
||||
updateButton();
|
||||
syncPresence();
|
||||
}
|
||||
});
|
||||
|
||||
function renderSections() {
|
||||
const available = {
|
||||
@@ -38,6 +105,7 @@ function renderSections() {
|
||||
$("chat-form").hidden = !token;
|
||||
$("chat-login").hidden = !!token;
|
||||
$("section-hint").hidden = selectedSection !== null;
|
||||
$("presence-opt-in").checked = sharePresence;
|
||||
}
|
||||
|
||||
$("section-nav").addEventListener("click", (event) => {
|
||||
@@ -48,6 +116,7 @@ $("section-nav").addEventListener("click", (event) => {
|
||||
if (selectedSection === "account-section") loadMe();
|
||||
if (selectedSection === "leaderboard-section") loadLeaderboard();
|
||||
if (selectedSection === "chat-section") loadChat();
|
||||
syncPresence();
|
||||
});
|
||||
|
||||
async function api(method, path, body) {
|
||||
@@ -190,8 +259,10 @@ async function refreshState() {
|
||||
if (first) buildStaticUi();
|
||||
if (game.ended && !statsLoaded) loadStats();
|
||||
updateButton();
|
||||
await syncPresence();
|
||||
await loadLeaderboard();
|
||||
if (selectedSection === "chat-section") await loadChat();
|
||||
if (selectedSection === "account-section") await loadMe();
|
||||
}
|
||||
|
||||
function updateButton() {
|
||||
@@ -225,6 +296,31 @@ async function loadMe() {
|
||||
$("my-name").textContent = me.name;
|
||||
$("my-points").textContent = me.total_points.toLocaleString();
|
||||
$("my-presses").textContent = me.press_count;
|
||||
const closest = me.personal_bests.closest_call;
|
||||
const best = me.personal_bests.highest_scoring_press;
|
||||
$("my-closest-call").textContent = closest ? formatPrecise(closest.remaining_ms) : "--";
|
||||
$("my-closest-detail").textContent = closest
|
||||
? `${closest.zone} zone / ${new Date(closest.at).toLocaleDateString()}`
|
||||
: "Your first press starts the story.";
|
||||
$("my-best-press").textContent = best ? `${best.points.toLocaleString()} pts` : "--";
|
||||
$("my-best-detail").textContent = best
|
||||
? `${best.zone} zone / ${formatPrecise(best.remaining_ms)} left`
|
||||
: "No presses yet. Wait for your moment.";
|
||||
const ranking = me.ranking;
|
||||
$("my-position").textContent = `#${ranking.position} of ${ranking.total_players}`;
|
||||
const rival = ranking.rival;
|
||||
if (rival) {
|
||||
$("my-rival-title").textContent = rival.points_to_tie === 0 ? `Tied with ${rival.name}` : `Next up: ${rival.name}`;
|
||||
$("my-rival-description").textContent = rival.points_to_tie === 0
|
||||
? `You're both on ${me.total_points.toLocaleString()} points. Gain points to move ahead.`
|
||||
: `${rival.points_to_tie.toLocaleString()} more points to match their ${rival.total_points.toLocaleString()}; ${rival.points_to_pass.toLocaleString()} to beat it.`;
|
||||
} else {
|
||||
$("my-rival-title").textContent = ranking.total_players === 1 ? "The first to take a seat." : ranking.tied_for_lead ? "Sharing the lead." : "You're setting the pace.";
|
||||
$("my-rival-description").textContent = ranking.total_players === 1
|
||||
? "You're the only player so far. Your next challenge is your own best." : ranking.tied_for_lead
|
||||
? "You're first alphabetically among equal top scores. Gain points to take a clear lead."
|
||||
: "You're at the top of the leaderboard. Make your next press count.";
|
||||
}
|
||||
subscribed = me.subscribed;
|
||||
updateButton();
|
||||
fillTable($("my-zones"), ["Zone", "Points each", "Your presses"], zoneRows(me.zone_counts));
|
||||
@@ -394,6 +490,7 @@ $("register-form").addEventListener("submit", async (ev) => {
|
||||
});
|
||||
token = r.token;
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
syncPresence();
|
||||
$("password-input").value = "";
|
||||
updateButton();
|
||||
await loadMe();
|
||||
@@ -412,6 +509,7 @@ $("logout-button").addEventListener("click", async () => {
|
||||
$("press-result").textContent = "";
|
||||
$("subscribe-result").textContent = "";
|
||||
updateButton();
|
||||
syncPresence();
|
||||
});
|
||||
|
||||
$("subscribe-form").addEventListener("submit", async (ev) => {
|
||||
|
||||
Reference in New Issue
Block a user