A listener's profile, in the jev panel (Profile.jsx): every name the site
shows (a song's, a comment's or a pitch's author, a name in the listening
tab or the activity feed, an account on the data tab) opens it through
openProfile. It is the account's view on the data tab
(GET /jev/data/accounts/<id>, worker/src/data.ts), shaped for listeners:
its songs as cards, what it wrote that is public, what it played lately
(its takes and its radio's plays, playedLately), and what its tabs are
playing now (the lobby, lobby.mjs). Accounts are
public (worker/README.md, What is public).
10import { asSong } from './listeners.mjs';
How much of each list a profile shows.
15export const RECENT_PLAYS = 20;
── which profile is open ─────────────────────────
onOpen lets the panel open itself (Panel.jsx), which this module does
not import.
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}
What the profile shows, from the account's view and the lobby's tabs. Its songs are listener songs, to play only as the song browser plays them (in the sandbox); what it wrote is split into what is public and 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}
What an account played lately, newest first: every take it recorded (a
performance played signed in, worker/src/listening.ts), each with its
replay, and every radio play that has no take, { at, song, title, take }
(take null for a play). A radio play's take is the first take of the
same song recorded after the play started and before the radio played
that song again: the recorder stores a take when the song ends or is
replaced, which can be a moment after the radio's next play began, so
the next play of any song is not the bound. A take of a song played from
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}