tetratto/crates/app/src/public/js/me.js
trisua bf27c51ad3 add: user grants
TODO: add grant creation api, grant tab in sessions tab of settings, grant api endpoints
2025-05-31 14:38:38 -04:00

902 lines
28 KiB
JavaScript

(() => {
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 += `<button class="quaternary w-full justify-start" onclick="trigger('me::login', ['${token[0]}'])">
<img
title="${token[0]}'s avatar"
src="/api/v1/auth/user/${token[0]}/avatar?selector_type=username"
alt="Avatar image"
class="avatar"
style="--size: 24px"
/>
<span>${token[0]}</span>
</button>`;
}
});
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 (_) => {
// 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) => {
// <https://developer.spotify.com/documentation/web-api/reference/get-information-about-the-users-current-playback>
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)} <span class="fade">(${Math.floor((progress_ms / duration_ms) * 100)}%)</span>`;
}
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) => {
// <https://lastfm-docs.github.io/api-docs/user/getRecentTracks/>
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(),
},
},
]);
});
})();