jevstrudel.git / website / src / jev / listeners.mjs

The page's side of listeners' content (worker/src/content.ts): songs signed-in listeners publish from the editor, their covers, comments on any song, and pitches for Jev songs. Everything is screened by Jev before it is public; the author sees their own held and pending items with why (statusText). Reading needs no account; without the Worker (a static host) every call fails and the page shows no listeners' content.

7export const LISTENERS = '/jev/listeners';

A listener song's id as the page knows it (nowPlaying, comments): listener:<id>, which never looks like a site song's <theme>/<song>.

10export const LISTENER_PREFIX = 'listener:';
11export const LISTENER_PARAM = 'listener';
12const ID = /^[A-Za-z0-9_-]{22}$/;
14async function call(path, { method = 'GET', body, form, fetchImpl = globalThis.fetch } = {}) {
15  const res = await fetchImpl(`${LISTENERS}/${path}`, {
16    method,
17    credentials: 'same-origin',
18    headers: body !== undefined ? { 'Content-Type': 'application/json' } : {},
19    body: form ?? (body !== undefined ? JSON.stringify(body) : undefined),
20  });
21  let parsed = null;
22  try {
23    parsed = await res.json();
24  } catch {
25    // a static host's 404 page, or a proxy's error
26  }
27  if (!res.ok) throw new Error(parsed?.error ?? `the site answered ${res.status}`);
28  return parsed;
29}
30
31export const listSongs = (o) => call('songs', o).then((b) => b.songs);
32export const getSong = (id, o) => call(`songs/${id}`, o);
33export const publishSong = (fields, o) => call('songs', { ...o, method: 'POST', body: fields });
34export const reviseSong = (id, fields, o) => call(`songs/${id}/revisions`, { ...o, method: 'POST', body: fields });
35export function uploadCover(id, file, alt, o) {
36  const form = new FormData();
37  form.set('image', file);
38  form.set('alt', alt);
39  return call(`songs/${id}/cover`, { ...o, method: 'PUT', form });
40}
41export const listComments = (song, o) => call(`comments?song=${encodeURIComponent(song)}`, o).then((b) => b.comments);
42export const postComment = (song, body, o) => call('comments', { ...o, method: 'POST', body: { song, body } });

sort is 'new' (the default) or 'votes'; each pitch has its votes, whether you voted (signed in), and the listener songs that answers it

45export const listPitches = (o = {}) =>
46  call(o.sort && o.sort !== 'new' ? `pitches?sort=${encodeURIComponent(o.sort)}` : 'pitches', o).then((b) => b.pitches);
47export const postPitch = (body, o) => call('pitches', { ...o, method: 'POST', body: { body } });

your vote for a public pitch, cast or taken back → { votes, voted }

49export const votePitch = (id, on, o) => call(`pitches/${id}/vote`, { ...o, method: on ? 'PUT' : 'DELETE' });

the pitch your song answers (null for none); its author only

51export const setSongPitch = (songId, pitch, o) => call(`songs/${songId}/pitch`, { ...o, method: 'PUT', body: { pitch } });
52export const mine = (o) => call('mine', o);
53export const coverUrl = (id) => `${LISTENERS}/covers/${id}`;

A listener's song in the shape the song browser uses for the site's own (songs.mjs), from the Worker's list entry or its whole view (which also has the code, spec and revisions). Its code is foreignCode, never code: it plays only in the sandbox (sandbox.mjs), and every path that plays a site's song reads code, so none of them can take it.

60export function asSong(s) {
61  return {
62    id: `${LISTENER_PREFIX}${s.id}`,
63    listenerId: s.id,
64    author: s.author,
65    authorId: s.authorId ?? null,
66    title: s.title,
67    theme: 'listeners',
68    description: s.description,
69    rev: s.rev,
70    timing: null,
71    art: s.art ? { p: s.art.art, model: s.art.model, method: s.art.method, criteria: s.art.criteria } : null,
72    cover: s.cover ? coverUrl(s.cover.id) : null,
73    coverAlt: s.cover?.alt ?? '',
74    foreignCode: s.code ?? null,
75    spec: s.spec ?? '',
76    revisions: s.revisions ?? [],
77    // the pitch it answers, its author's say (pitches.mjs)
78    pitch: s.pitch ?? null,
79  };
80}

The listener songs this page has loaded whole, by page id, so the song view can find whatever is playing (nowPlaying holds its id).

84const loaded = new Map();
85export const listenerSongs = {
86  get: (id) => loaded.get(id) ?? null,
87  set: (song) => void loaded.set(song.id, song),
88};

Loads a listener song whole (its code and spec), keeps it, and returns it.

91export async function loadListenerSong(id, o) {
92  const song = asSong(await getSong(id, o));
93  listenerSongs.set(song);
94  return song;
95}

The ?listener=<id> a listener song's address carries, or null.

98export function listenerParam(search = typeof location !== 'undefined' ? location.search : '') {
99  const id = new URLSearchParams(search).get(LISTENER_PARAM);
100  return id && ID.test(id) ? id : null;
101}

The title a song's code gives itself (// @title …), for the publish form.

104export const codeTitle = (code) => code?.match(/^\/\/ @title (.+)$/m)?.[1]?.trim() ?? '';
106const WHY = {
107  new: 'waiting for Jev',
108  budget: 'your Jev budget for today is spent',
109  'budget-unavailable': 'your Jev budget could not be checked',
110  unreachable: 'Jev could not be reached',
111  upstream: 'Jev answered with an error',
112  unanswered: 'Jev did not give a verdict',
113};
114const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });

An item's status (content-store.ts's Status), in words for its author.

117export function statusText(item, now = Date.now()) {
118  if (item.status === 'public') return 'public';
119  if (item.status === 'held') {
120    const sure = typeof item.confidence === 'number' ? `, ${Math.round(item.confidence * 100)}% sure` : '';
121    return `held: Jev says ${item.verdict}${sure}; only you can see it`;
122  }
123  const when = item.next > now ? `; Jev is asked again at ${clock(item.next)}` : '; Jev is asked again shortly';
124  return `pending: ${WHY[item.why] ?? item.why}${when}`;
125}

A fine revision whose score is still owed, in words.

128export function scoreText(revision, now = Date.now()) {
129  if (revision.art !== null && revision.art !== undefined) return `${Math.round(revision.art * 100)}% art`;
130  if (!revision.score) return revision.status === 'public' ? 'art: being scored' : '';
131  const s = revision.score;
132  const when = s.next > now ? ` (asked again at ${clock(s.next)})` : '';
133  return `art pending: ${WHY[s.why] ?? s.why}${when}`;
134}