(() => { const self = reg_ns("me", ["streams"]); self.LOGIN_ACCOUNT_TOKENS = JSON.parse( window.localStorage.getItem("atto:login_account_tokens") || "{}", ); self.define("logout", async () => { if ( !(await trigger("atto::confirm", [ "Are you sure you would like to do this?", ])) ) { return; } fetch("/api/v1/auth/logout", { method: "POST", }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); if (res.ok) { delete self.LOGIN_ACCOUNT_TOKENS[res.payload]; self.set_login_account_tokens(self.LOGIN_ACCOUNT_TOKENS); const next = Object.entries(self.LOGIN_ACCOUNT_TOKENS)[0]; if (next) { self.login(next[0]); } else { setTimeout(() => { window.location.href = "/"; }, 150); } } }); }); self.define("remove_post", async (_, id) => { if ( !(await trigger("atto::confirm", [ "Are you sure you want to do this?", ])) ) { return; } fetch(`/api/v1/posts/${id}`, { method: "DELETE", }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); }); }); self.define("purge_post", async (_, id) => { if ( !(await trigger("atto::confirm", [ "Are you sure you want to do this? This cannot be undone.", ])) ) { return; } fetch(`/api/v1/posts/${id}/purge`, { method: "DELETE", }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); }); }); self.define("restore_post", async (_, id) => { if ( !(await trigger("atto::confirm", [ "Are you sure you want to do this?", ])) ) { return; } fetch(`/api/v1/posts/${id}/restore`, { method: "POST", }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); }); }); self.define("react", async (_, element, asset, asset_type, is_like) => { await trigger("atto::debounce", ["reactions::toggle"]); fetch("/api/v1/reactions", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ asset, asset_type, is_like, }), }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); if (res.ok) { const like = element.parentElement.querySelector( '[hook_element="reaction.like"]', ); const dislike = element.parentElement.querySelector( '[hook_element="reaction.dislike"]', ); if (is_like) { like.classList.add("green"); like.querySelector("svg").classList.add("filled"); dislike.classList.remove("red"); } else { dislike.classList.add("red"); like.classList.remove("green"); like.querySelector("svg").classList.remove("filled"); } } }); }); self.define("remove_notification", (_, id) => { fetch(`/api/v1/notifications/${id}`, { method: "DELETE", }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); }); }); self.define("update_notification_read_status", (_, id, read) => { fetch(`/api/v1/notifications/${id}/read_status`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ read, }), }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); }); }); self.define("clear_notifs", async () => { if ( !(await trigger("atto::confirm", [ "Are you sure you want to do this?", ])) ) { return; } fetch("/api/v1/notifications/my", { method: "DELETE", }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); }); }); self.define( "repost", (_, id, content, community, do_not_redirect = false) => { return new Promise((resolve, _) => { fetch(`/api/v1/posts/${id}/repost`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ content, community, }), }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); if (res.ok) { if (!do_not_redirect) { setTimeout(() => { window.location.href = `/post/${res.payload}`; }, 100); } resolve(res.payload); } }); }); }, ); self.define("report", (_, asset, asset_type) => { window.open( `/mod_panel/file_report?asset=${asset}&asset_type=${asset_type}`, ); }); self.define("seen", () => { fetch("/api/v1/auth/user/me/seen", { method: "POST", }) .then((res) => res.json()) .then((res) => { if (!res.ok) { trigger("atto::toast", ["error", res.message]); } }); }); self.define("remove_question", async (_, id) => { if ( !(await trigger("atto::confirm", [ "Are you sure you want to do this?", ])) ) { return; } fetch(`/api/v1/questions/${id}`, { method: "DELETE", }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); }); }); self.define("ip_block_question", async (_, id) => { if ( !(await trigger("atto::confirm", [ "Are you sure you want to do this?", ])) ) { return; } fetch(`/api/v1/questions/${id}/block_ip`, { method: "POST", }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); }); }); self.define("notifications_stream", ({ _, streams }) => { const element = document.getElementById("notifications_span"); streams.subscribe("notifs"); streams.event("notifs", "message", (data) => { if (!data.method.Packet) { console.warn("notifications stream cannot read this message"); return; } const inner_data = JSON.parse(data.data); if (data.method.Packet.Crud === "Create") { const current = Number.parseInt(element.innerText || "0"); if (current <= 0) { element.classList.remove("hidden"); } element.innerText = current + 1; // check if we're already connected const connected = window.sessionStorage.getItem("atto:connected/notifs") === "true"; if (connected) { return; } window.sessionStorage.setItem("atto:connected/notifs", "true"); // send notification const enabled = window.localStorage.getItem("atto:notifs_enabled") === "true"; if (Notification.permission === "granted" && enabled) { // try to pull notification user const matches = /\/api\/v1\/auth\/user\/find\/(\d*)/.exec( inner_data.content, ); // ... new Notification(inner_data.title, { body: inner_data.content, icon: matches[1] ? `/api/v1/auth/user/${matches[1]}/avatar?selector_type=id` : "/public/favicon.svg", lang: "en-US", }); console.info("notification created"); } } else if (data.method.Packet.Crud === "Delete") { const current = Number.parseInt(element.innerText || "0"); if (current - 1 <= 0) { element.classList.add("hidden"); } element.innerText = current - 1; } else { console.warn("correct packet type but with wrong data"); } }); }); self.define("notifications_button", (_, element) => { if (Notification.permission === "granted") { let enabled = window.localStorage.getItem("atto:notifs_enabled") === "true"; function text() { if (!enabled) { element.innerText = "Enable notifications"; } else { element.innerText = "Disable notifications"; } } element.addEventListener("click", () => { enabled = !enabled; window.localStorage.setItem("atto:notifs_enabled", enabled); text(); }); text(); } else if (Notification.permission !== "denied") { element.innerText = "Enable notifications"; element.addEventListener("click", () => { Notification.requestPermission().then((permission) => { if (permission === "granted") { window.localStorage.setItem( "atto:notifs_enabled", "true", ); window.location.reload(); } else { alert( "Permission denied! You must allow this permission for browser notifications.", ); } }); }); } }); self.define("emojis", async () => { const payload = (await (await fetch("/api/v1/my_emojis")).json()) .payload; const out = []; for (const [community, [category, emojis]] of Object.entries(payload)) { for (const emoji of emojis) { out.push({ category, name: emoji.name, shortcodes: [`${community}.${emoji.name}`], url: `/api/v1/communities/${community}/emojis/${emoji.name}`, }); } } return out; }); // token switcher self.define( "set_login_account_tokens", ({ $ }, value) => { $.LOGIN_ACCOUNT_TOKENS = value; window.localStorage.setItem( "atto:login_account_tokens", JSON.stringify(value), ); }, ["object"], ); self.define("login", ({ $ }, username) => { const token = self.LOGIN_ACCOUNT_TOKENS[username]; if (!token) { return; } window.location.href = `/api/v1/auth/token?token=${token}`; }); self.define("render_token_picker", ({ $ }, element) => { element.innerHTML = ""; for (const token of Object.entries($.LOGIN_ACCOUNT_TOKENS)) { element.innerHTML += ``; } }); self.define("switch_account", () => { document.getElementById("tokens_dialog").showModal(); }); })(); (() => { const self = reg_ns("connections"); self.define("pkce_verifier", async (_, length) => { let text = ""; const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; for (let i = 0; i < length; i++) { text += possible.charAt( Math.floor(Math.random() * possible.length), ); } return text; }); self.define("pkce_challenge", async (_, verifier) => { const data = new TextEncoder().encode(verifier); const digest = await window.crypto.subtle.digest("SHA-256", data); return btoa( String.fromCharCode.apply(null, [...new Uint8Array(digest)]), ) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); }); self.define("delete", async (_, connection) => { if ( !(await trigger("atto::confirm", [ "Are you sure you want to do this?", ])) ) { return; } fetch(`/api/v1/auth/user/connections/${connection}`, { method: "DELETE", }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); if (res.ok) { window.location.reload(); } }); }); self.define("push_con_data", async (_, connection, data) => { return await ( await fetch("/api/v1/auth/user/connections/_data", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ connection, data, }), }) ).json(); }); self.define("push_con_state", async (_, connection, data) => { return await ( await fetch("/api/v1/auth/user/connections/_state", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ connection, data, }), }) ).json(); }); self.define("push_con_shown", async (_, connection, shown) => { fetch("/api/v1/auth/user/connections/_shown", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ connection, shown, }), }) .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ res.ok ? "success" : "error", res.message, ]); }); }); })(); (() => { const self = reg_ns("spotify"); self.define("create_connection", (_, client_id) => { fetch("/api/v1/auth/user/connections/spotify", { method: "POST", }) .then((res) => res.json()) .then(async (res) => { // create challenge and store const verifier = await trigger("connections::pkce_verifier", [ 128, ]); const challenge = await trigger("connections::pkce_challenge", [ verifier, ]); await trigger("connections::push_con_data", [ "Spotify", { verifier, challenge, }, ]); // ... const params = new URLSearchParams(); params.append("client_id", client_id); params.append("response_type", "code"); params.append( "redirect_uri", `${window.location.origin}/auth/connections_link/Spotify`, ); params.append( "scope", "user-read-recently-played user-read-playback-state user-read-currently-playing", ); params.append("code_challenge_method", "S256"); params.append("code_challenge", challenge); window.location.href = `https://accounts.spotify.com/authorize?${params.toString()}`; }); }); self.define("get_token", async (_, client_id, verifier, code) => { const params = new URLSearchParams(); params.append("client_id", client_id); params.append("grant_type", "authorization_code"); params.append("code", code); params.append( "redirect_uri", `${window.location.origin}/auth/connections_link/Spotify`, ); params.append("code_verifier", verifier); const result = await fetch("https://accounts.spotify.com/api/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params, }); const { access_token, refresh_token, expires_in } = await result.json(); return [access_token, refresh_token, expires_in]; }); self.define("refresh_token", async (_, client_id, token) => { const params = new URLSearchParams(); params.append("client_id", client_id); params.append("grant_type", "refresh_token"); params.append("refresh_token", token); const result = await fetch("https://accounts.spotify.com/api/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params, }); const { access_token, refresh_token, expires_in } = await result.json(); return [access_token, refresh_token, expires_in]; }); self.define("profile", async (_, token) => { return await ( await fetch("https://api.spotify.com/v1/me", { method: "GET", headers: { Authorization: `Bearer ${token}` }, }) ).json(); }); self.define("get_playing", async (_, token) => { // return await ( await fetch("https://api.spotify.com/v1/me/player", { method: "GET", headers: { Authorization: `Bearer ${token}` }, }) ).json(); }); self.define("publish_playing", async (_, playing) => { if (!playing.is_playing) { return await trigger("connections::push_con_state", [ "Spotify", { external_urls: {}, data: {}, }, ]); } if (playing.item.is_local) { return; } if ( window.localStorage.getItem("atto:connections.spotify/id") === playing.item.id ) { // item already pushed to connection, no need right now return; } window.localStorage.setItem( "atto:connections.spotify/id", playing.item.id, ); return await trigger("connections::push_con_state", [ "Spotify", { external_urls: { track: playing.item.external_urls.spotify, artist: playing.item.artists[0].external_urls.spotify, album: playing.item.album.external_urls.spotify, album_img: playing.item.album.images[0].url, }, data: { id: playing.item.id, // track track: playing.item.name, artist: playing.item.artists[0].name, album: playing.item.album.name, // image // img_w: playing.item.album.images[0].width.toString(), // img_h: playing.item.album.images[0].width.toString(), // times timestamp: playing.timestamp.toString(), progress_ms: playing.progress_ms.toString(), duration_ms: playing.item.duration_ms.toString(), }, }, ]); }); self.define("ms_time_text", (_, ms) => { const minutes = Math.floor(ms / 60000); const seconds = ((ms % 60000) / 1000).toFixed(0); return `${minutes}:${(seconds < 10 ? "0" : "") + seconds}`; }); self.define( "timestamp", ({ $ }, updated_, progress_ms_, duration_ms_, display = "full") => { if (duration_ms_ === "0") { return; } const now = new Date().getTime(); const updated = Number.parseInt(updated_) + 8000; let elapsed_since_update = now - updated; if (elapsed_since_update < 0) { elapsed_since_update = 0; } const progress_ms = Number.parseInt(progress_ms_) + elapsed_since_update; const duration_ms = Number.parseInt(duration_ms_); if (progress_ms > duration_ms) { // song is over return ""; } if (display === "full") { return `${$.ms_time_text(progress_ms)}/${$.ms_time_text(duration_ms)} (${Math.floor((progress_ms / duration_ms) * 100)}%)`; } if (display === "left") { return $.ms_time_text(progress_ms); } return $.ms_time_text(duration_ms); }, ); })(); (() => { const self = reg_ns("last_fm"); self.define("api", async (_, method, data) => { return JSON.parse( ( await ( await fetch( "/api/v1/auth/user/connections/last_fm/api_proxy", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ method, data, }), }, ) ).json() ).payload, ); }); self.define("create_connection", (_, client_id) => { fetch("/api/v1/auth/user/connections/last_fm", { method: "POST", }) .then((res) => res.json()) .then(async (_) => { const params = new URLSearchParams(); params.append("api_key", client_id); params.append( "cb", `${window.location.origin}/auth/connections_link/LastFm`, ); window.location.href = `https://last.fm/api/auth?${params.toString()}`; }); }); self.define("pull_track_info", async (_, artist, name) => { const params = new URLSearchParams(); params.append("query", `query=artist:"${artist}" and track:"${name}"`); params.append("limit", "1"); params.append("fmt", "json"); return ( await ( await fetch( `https://musicbrainz.org/ws/2/recording?${params.toString()}`, ) ).json() ).recordings[0]; }); self.define("get_session", async ({ $ }, token) => { return await $.api("auth.getSession", { token, }); }); self.define("get_playing", async ({ $ }, user, session_token) => { // return ( await $.api("user.getRecentTracks", { user, sk: session_token, limit: "1", extended: "1", }) ).recenttracks.track[0]; }); self.define("publish_playing", async ({ $ }, playing) => { if (!playing || !playing["@attr"] || !playing["@attr"].nowplaying) { return await trigger("connections::push_con_state", [ "LastFm", { external_urls: {}, data: {}, }, ]); } if ( window.localStorage.getItem("atto:connections.last_fm/name") === playing.name ) { // item already pushed to connection, no need right now return; } window.localStorage.setItem( "atto:connections.last_fm/name", playing.name, ); const mb_info = await $.pull_track_info( playing.artist.name, playing.name, ); return await trigger("connections::push_con_state", [ "LastFm", { external_urls: { track: playing.url, artist: playing.artist.url, track_img: playing.image[2]["#text"], }, data: { id: mb_info.id, // track track: playing.name, artist: playing.artist.name, album: playing.album["#text"], // times timestamp: new Date().getTime().toString(), duration_ms: (mb_info.length || 0).toString(), }, }, ]); }); })();