1// The jev panel's activity tab (activity.mjs): what is happening on the 2// site, newest first, with more on request. Names open profiles; a 3// listener song plays in the sandbox as its card does, and a take (a site 4// song's or a listener's) replays from its link (performance.mjs). Text only. 5import { useEffect, useState } from 'react'; 6import { loadListenerSong as showListenerSong, readSongs } from './client.mjs'; 7import { appendPage, describe, loadActivity } from './activity.mjs'; 8import { loadListenerSong } from './listeners.mjs'; 9import { NameLink } from './NameLink.jsx'; 10import { performancePath } from './performance.mjs'; 11 12const when = (ms) => new Date(ms).toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }); 13 14export function ActivityTab({ context }) { 15 const [items, setItems] = useState([]); 16 const [next, setNext] = useState(null); 17 const [state, setState] = useState('loading'); // loading | idle | error 18 const [error, setError] = useState(null); 19 const songs = readSongs(); 20 const title = (id) => songs.find((s) => s.id === id)?.title ?? id; 21 22 const load = async (before, fresh = false) => { 23 setState('loading'); 24 try { 25 const page = await loadActivity(before); 26 setItems((old) => (fresh ? page.items : appendPage(old, page.items))); 27 setNext(page.next); 28 setState('idle'); 29 } catch (e) { 30 setError(e.message); 31 setState('error'); 32 } 33 }; 34 useEffect(() => void load(null, true), []); 35 36 const editor = context.editorRef.current; 37 const openListener = async (id) => { 38 try { 39 await showListenerSong(editor, await loadListenerSong(id), { play: true }); 40 } catch (e) { 41 setError(`that song could not be loaded: ${e.message}`); 42 } 43 }; 44 45 return ( 46 <div className="px-4 py-3 text-sm font-mono" data-testid="jev-activity"> 47 <div className="flex items-center gap-2 mb-2"> 48 <span className="opacity-70 grow">What is happening on the site, newest first.</span> 49 <button className="underline hover:opacity-60 text-xs" onClick={() => load(null, true)}> 50 refresh 51 </button> 52 </div> 53 {error && <p className="text-red-400 text-xs">{error}</p>} 54 {state !== 'loading' && !items.length && !error && <p className="opacity-60">Nothing yet.</p>} 55 <ol className="space-y-2"> 56 {items.map((i) => ( 57 <li key={`${i.kind}:${i.id ?? i.song}:${i.rev ?? ''}:${i.at}`} className="leading-snug"> 58 <span className="opacity-50 text-xs">{when(i.at)} </span> 59 {i.author && ( 60 <> 61 <NameLink id={i.author.id} name={i.author.name} />{' '} 62 </> 63 )} 64 {i.kind === 'take' && ( 65 <> 66 <NameLink id={i.player?.id} name={i.player?.name} />{' '} 67 </> 68 )} 69 <span style={{ color: i.screen === 'spam' || i.screen === 'abuse' ? '#f87171' : undefined }}> 70 {describe(i, title)} 71 </span> 72 {/* a public revision's song plays its newest public revision */} 73 {((i.kind === 'revision' && i.screen === 'fine') || i.kind === 'scored') && ( 74 <button className="ml-1 text-xs underline opacity-70 hover:opacity-100" onClick={() => openListener(i.song)}> 75 play 76 </button> 77 )} 78 {i.kind === 'take' && ( 79 <a 80 className="ml-1 text-xs underline opacity-70 hover:opacity-100" 81 href={performancePath(i.song, i.id)} 82 title="replay this take: Jev's decisions as they were, with no Jev calls" 83 > 84 ▶ replay 85 </a> 86 )} 87 {i.body && <p className="whitespace-pre-wrap break-words opacity-90 mt-0.5">{i.body}</p>} 88 </li> 89 ))} 90 </ol> 91 {state === 'loading' && <p className="opacity-60 mt-2">reading…</p>} 92 {state === 'idle' && next !== null && ( 93 <button className="mt-3 underline hover:opacity-60" onClick={() => load(next)}> 94 older 95 </button> 96 )} 97 </div> 98 ); 99}