jevstrudel.git / website / src / jev / LiveParties.jsx
LiveParties.jsx98 lines · 3.9 KB · raw
1// The listening tab's live parties (parties.mjs): each one's song, how many
2// are in it and whether its host is, with "join" for a listed one. Read
3// again every POLL_MS while shown, and as this page joins or leaves one.
4import { useEffect, useState, useSyncExternalStore } from 'react';
5import { readSongs } from './client.mjs';
6import { joinListed, loadParties } from './parties.mjs';
7import { partyStore } from './party.mjs';
8import { themeColor as ACCENT } from './site.mjs';
9
10const POLL_MS = 15_000;
11const since = (ms) => {
12  const min = Math.max(0, Math.round((Date.now() - ms) / 60_000));
13  return min < 60 ? `${min} min` : `${Math.floor(min / 60)} h ${min % 60} min`;
14};
15
16// `song` narrows it to one site song's parties (a song's page,
17// SongListening.jsx).
18export function LiveParties({ song = null } = {}) {
19  const party = useSyncExternalStore(partyStore.subscribe, partyStore.get, partyStore.get);
20  const [parties, setParties] = useState(null);
21  const [error, setError] = useState(null);
22  const [joining, setJoining] = useState(null);
23  useEffect(() => {
24    let live = true;
25    const read = () =>
26      loadParties().then(
27        (p) => live && (setParties(song ? p.filter((x) => x.song === song) : p), setError(null)),
28        (e) => live && setError(e.message),
29      );
30    read();
31    const t = setInterval(read, POLL_MS);
32    return () => {
33      live = false;
34      clearInterval(t);
35    };
36    // read again as this page joins or leaves a party, which changes the counts
37  }, [song, party.status]);
38  const songs = readSongs();
39  const title = (id) => (id ? (songs.find((s) => s.id === id)?.title ?? id) : 'between songs');
40  const inParty = party.status !== 'off' && party.status !== 'ended';
41
42  const join = async (id) => {
43    setJoining(id);
44    setError(null);
45    try {
46      await joinListed(id);
47    } catch (e) {
48      setError(e.message);
49    } finally {
50      setJoining(null);
51    }
52  };
53
54  return (
55    <div className="my-3 text-sm rounded border px-3 py-2" style={{ borderColor: ACCENT }} data-testid="jev-parties">
56      <b>{song ? '🎉 parties on this song' : '🎉 listening parties'}</b>
57      <span className="opacity-70"> · {parties ? `${parties.length} live` : 'reading…'}</span>
58      {error && <div className="text-xs text-red-400 mt-1">{error}</div>}
59      {parties && parties.length === 0 && (
60        <div className="opacity-60 mt-1">
61          {song ? 'No party on this song right now: start one with 👥 listen together.' : "No party right now. Start one from a song's view: 👥 listen together."}
62        </div>
63      )}
64      {parties && parties.length > 0 && (
65        <ul className="mt-1 space-y-1">
66          {parties.map((p, i) => (
67            <li key={p.id ?? `unlisted-${i}`} className="flex flex-wrap items-center gap-x-3">
68              <span className="grow min-w-0">
69                <b>{title(p.song)}</b>
70                <span className="opacity-60">
71                  {' '}
72                  · {p.people} {p.people === 1 ? 'person' : 'people'}
73                  {p.host ? '' : ' · host away'}
74                  {p.since ? ` · ${since(p.since)}` : ''}
75                </span>
76              </span>
77              {p.id ? (
78                <button
79                  className="rounded border px-2 py-0.5 hover:opacity-80 disabled:opacity-50"
80                  style={{ borderColor: ACCENT }}
81                  disabled={Boolean(joining) || inParty || !p.host}
82                  title={inParty ? 'you are in a party already' : 'join as a guest: hear what they hear, in sync'}
83                  onClick={() => join(p.id)}
84                >
85                  {joining === p.id ? 'joining…' : 'join'}
86                </button>
87              ) : (
88                <span className="text-xs opacity-60" title="its host hides from the listening list">
89                  private
90                </span>
91              )}
92            </li>
93          ))}
94        </ul>
95      )}
96    </div>
97  );
98}