jevstrudel.git / website / src / jev / profile.mjs
1// A listener's profile, in the jev panel (Profile.jsx): every name the site
2// shows (a song's, a comment's or a pitch's author, a name in the listening
3// tab or the activity feed, an account on the data tab) opens it through
4// `openProfile`. It is the account's view on the data tab
5// (GET /jev/data/accounts/<id>, worker/src/data.ts), shaped for listeners:
6// its songs as cards, what it wrote that is public, what it played lately
7// (its takes and its radio's plays, `playedLately`), and what its tabs are
8// playing now (the lobby, lobby.mjs). Accounts are
9// public (worker/README.md, What is public).
10import { asSong } from './listeners.mjs';
11
12const ID = /^[A-Za-z0-9_-]{22}$/;
13export const DATA = '/jev/data/accounts/';
14// How much of each list a profile shows.
15export const RECENT_PLAYS = 20;
16
17// ── which profile is open ─────────────────────────
18let state = { id: null };
19const listeners = new Set();
20export const profileStore = {
21  subscribe(l) {
22    listeners.add(l);
23    return () => listeners.delete(l);
24  },
25  get: () => state,
26};
27function set(next) {
28  state = next;
29  for (const l of listeners) l();
30}
31
32// `onOpen` lets the panel open itself (Panel.jsx), which this module does
33// not import.
34let onOpen = () => {};
35export const setOnOpenProfile = (fn) => void (onOpen = fn);
36
37export function openProfile(id) {
38  if (typeof id !== 'string' || !ID.test(id)) return;
39  set({ id });
40  onOpen();
41}
42export const closeProfile = () => set({ id: null });
43
44export async function loadProfile(id, fetcher = fetch) {
45  const res = await fetcher(`${DATA}${id}`, { headers: { Accept: 'application/json' } });
46  if (res.status === 404) throw new Error('no such listener');
47  if (!res.ok) throw new Error(`the site answered ${res.status}`);
48  return res.json();
49}
50
51// What the profile shows, from the account's view and the lobby's tabs.
52// Its songs are listener songs, to play only as the song browser plays
53// them (in the sandbox); what it wrote is split into what is public and
54// counts of what is pending or held (the data tab has those whole).
55export function profileView(account, tabs = []) {
56  const content = account.content ?? {};
57  const split = (items = []) => ({
58    shown: items.filter((i) => i.status === 'public'),
59    pending: items.filter((i) => i.status === 'pending').length,
60    held: items.filter((i) => i.status === 'held').length,
61  });
62  return {
63    id: account.id,
64    name: account.name,
65    joined: account.joined,
66    songs: (account.publicSongs ?? []).map(asSong),
67    // revisions Jev has not made public: waiting on it, or held
68    revisions: { pending: split(content.revisions).pending, held: split(content.revisions).held },
69    comments: split(content.comments),
70    pitches: split(content.pitches),
71    played: playedLately(account.radio, account.takes, RECENT_PLAYS),
72    playing: tabs.filter((t) => t.account === account.id && t.playing),
73    listed: tabs.filter((t) => t.account === account.id).length,
74  };
75}
76
77// What an account played lately, newest first: every take it recorded (a
78// performance played signed in, worker/src/listening.ts), each with its
79// replay, and every radio play that has no take, `{ at, song, title, take }`
80// (`take` null for a play). A radio play's take is the first take of the
81// same song recorded after the play started and before the radio played
82// that song again: the recorder stores a take when the song ends or is
83// replaced, which can be a moment after the radio's next play began, so
84// the next play of any song is not the bound. A take of a song played from
85// its card, between two radio plays of it, then still shows as a take.
86export function playedLately(radio = [], takes = [], limit = Infinity) {
87  const plays = [...radio].sort((a, b) => a.at - b.at);
88  const byTime = [...takes].sort((a, b) => a.at - b.at);
89  const taken = new Set();
90  const unmatched = [];
91  plays.forEach((p, i) => {
92    const next = plays.slice(i + 1).find((q) => q.song === p.song)?.at ?? Infinity;
93    const take = byTime.find((t) => !taken.has(t) && t.song === p.song && t.at >= p.at && t.at < next);
94    if (take) taken.add(take);
95    else unmatched.push({ at: p.at, song: p.song, title: null, take: null });
96  });
97  return [...takes.map((t) => ({ at: t.at, song: t.song, title: t.title ?? null, take: t })), ...unmatched]
98    .sort((a, b) => b.at - a.at)
99    .slice(0, limit);
100}