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}