board

tcmts.examples.userinput.board · #sb3dnhl6 · tsx file

// tcm: namespace userinput.pages
// A space's board: its discussions, sorted and filtered, each with its votes, status and replies; and posting a new
// one. Votes change at once on the page and are written behind it.
import { useEffect, useMemo, useState } from "react";
import { Input, SearchField, ToggleButton } from "react-aria-components";
import { navigate, useData } from "#codebase/userinput/lib/react";
import { canModerate, filterItems, loadBoard, loadSpace, parseAtUri, readAhead, refOf, sortItems, STATE_GROUPS } from "#codebase/userinput";
import type { Item, SpaceView, Sort, StrongRef, Tally } from "#codebase/userinput";
import { postDiscussion, recordVisit, useMe, useSignedIn, vote } from "#codebase/userinput/account";
import type { ImageToPost } from "#codebase/userinput/account";
import {
  Button, Chips, discussionPath, Empty, Field, Icon, IconButton, ImageChooser, Link, Person, plainText, Segmented, Sheet, SkeletonRows, StatusPill,
  refreshSoon, Tag, Time, Title, toast, useAction,
} from "#codebase/userinput/design";
import { SpaceIcon } from "#codebase/userinput/pages";

/** Votes on something: up, down, and the score between, changed on the page at once and written after. */
export function Votes({ subject, tally, onSignIn, horizontal }: { subject: StrongRef; tally: Tally; onSignIn: () => void; horizontal?: boolean }) {
  const signed = useSignedIn();
  const [shown, setShown] = useState(tally);
  // (votes read again, after a write or a reload, replace what's shown)
  const [was, setWas] = useState(tally);
  if (tally !== was) {
    setWas(tally);
    setShown(tally);
  }
  const cast = (dir: "up" | "down") => {
    if (signed.state !== "in") return onSignIn();
    const to = shown.mine === dir ? undefined : dir;
    const was = shown.mine;
    const delta = (v: "up" | "down" | undefined) => (v === "up" ? 1 : v === "down" ? -1 : 0);
    const next = { ...shown, mine: to, score: shown.score - delta(was) + delta(to), up: shown.up - Number(was === "up") + Number(to === "up"), down: shown.down - Number(was === "down") + Number(to === "down") };
    setShown(next);
    vote(signed.session, subject, was, to).then(() => refreshSoon(false), (e: Error) => (setShown(shown), toast(e.message)));
  };
  return (
    <div className={`votes sq ${horizontal ? "horizontal" : ""}`}>
      <ToggleButton className="vote up" aria-label={`Upvote (score ${shown.score})`} isSelected={shown.mine === "up"} onChange={() => cast("up")}><Icon name="up" /></ToggleButton>
      <span className="score" aria-hidden>{shown.score}</span>
      <ToggleButton className="vote down" aria-label="Downvote" isSelected={shown.mine === "down"} onChange={() => cast("down")}><Icon name="down" /></ToggleButton>
    </div>
  );
}

/** A discussion, as a row of the board. */
export function DiscussionRow({ item, view, onSignIn }: { item: Item; view: SpaceView; onSignIn: () => void }) {
  const d = item.discussion;
  const labels = new Map((view.space.value.tags ?? []).map((t) => [t.value, t.label]));
  return (
    <article className="row">
      <Votes subject={refOf(d)} tally={item.tally} onSignIn={onSignIn} />
      <div className="row-main">
        <Link to={discussionPath(d.uri)} className="row-title" onReach={() => readAhead(d.uri)}>
          {item.pinned ? <span title="Pinned" style={{ color: "var(--accent)", marginRight: 8 }}><Icon name="pin" size={16} /></span> : null}
          {d.value.title}
        </Link>
        {d.value.body ? <div className="row-excerpt">{plainText(d.value.body)}</div> : null}
        <div className="meta">
          <StatusPill state={item.status} />
          {(d.value.tags ?? []).map((t) => <Tag key={t} label={labels.get(t) ?? t} />)}
          <Person did={parseAtUri(d.uri)!.did} size={20} />
          <Time iso={d.value.createdAt} />
          <span className="inline" style={{ gap: 4 }}><Icon name="bubble" size={16} />{item.replies}</span>
        </div>
      </div>
    </article>
  );
}

/** A space's name, icon, what it's for, and what you can do there. */
export function SpaceHeader({ view, count, onCompose }: { view: SpaceView; count?: number; onCompose: () => void }) {
  const me = useMe();
  const s = view.space;
  return (
    <header className="space-head">
      <SpaceIcon space={s} large />
      <div className="stack" style={{ gap: 6, flex: 1, minWidth: 0 }}>
        <h1 className="large-title" style={{ margin: 0 }}>{s.value.name}</h1>
        {s.value.description ? <p className="subhead">{s.value.description}</p> : null}
        <div className="meta">
          <span>by</span>
          <Person did={view.authority.owner} size={20} />
          {count !== undefined ? <span>· {count} {count === 1 ? "discussion" : "discussions"}</span> : null}
        </div>
      </div>
      <div className="inline">
        {canModerate(view.authority, me) ? <IconButton icon="gear" label="Space settings" onPress={() => navigate(`${location.pathname}/settings`)} /> : null}
        <Button variant="primary" icon="plus" onPress={onCompose} disabled={!!me && view.moderation.banned.has(me)}>New post</Button>
      </div>
    </header>
  );
}

/** The sorts a board offers. */
export const SORTS: { value: Sort; label: string }[] = [
  { value: "trending", label: "Trending" },
  { value: "top", label: "Top" },
  { value: "new", label: "New" },
];

/** A board's filters, kept in its URL (`?sort=new&group=Planned&tag=bug&q=dark`). */
export interface BoardQuery {
  sort: Sort;
  group?: string;
  tag?: string;
  q?: string;
}

/** The board's URL with some of its filters changed. */
export function boardUrl(q: BoardQuery): string {
  const p = new URLSearchParams();
  if (q.sort !== "trending") p.set("sort", q.sort);
  if (q.group) p.set("group", q.group);
  if (q.tag) p.set("tag", q.tag);
  if (q.q) p.set("q", q.q);
  const s = p.toString();
  return `${location.pathname}${s ? `?${s}` : ""}`;
}

/** How to look through a board: by sort, status group, tag, and words. */
export function Toolbar({ view, query, counts }: { view: SpaceView; query: BoardQuery; counts: Map<string, number> }) {
  const set = (change: Partial<BoardQuery>) => navigate(boardUrl({ ...query, ...change }), { replace: true });
  const [words, setWords] = useState(query.q ?? "");
  useEffect(() => {
    const t = setTimeout(() => words !== (query.q ?? "") && set({ q: words || undefined }), 250);
    return () => clearTimeout(t);
  });
  return (
    <>
      <div className="toolbar">
        <Segmented label="Sort" options={SORTS} value={query.sort} onChange={(sort) => set({ sort })} />
        <SearchField className="search sq" value={words} onChange={setWords} aria-label="Search this space">
          <Icon name="search" size={18} />
          <Input placeholder="Search" />
        </SearchField>
      </div>
      <div className="inline" style={{ marginBottom: 20, justifyContent: "space-between" }}>
        <Chips
          label="Status"
          mode="single"
          options={[{ value: "all", label: "All" }, ...STATE_GROUPS.map((g) => ({ value: g.label, label: `${g.label}${counts.get(g.label) ? ` ${counts.get(g.label)}` : ""}` }))]}
          selected={[query.group ?? "all"]}
          onChange={([g]) => set({ group: g && g !== "all" ? g : undefined })}
        />
        {view.space.value.tags?.length ? (
          <Chips label="Tag" options={view.space.value.tags.map((t) => ({ value: t.value, label: t.label }))} selected={query.tag ? [query.tag] : []} onChange={(t) => set({ tag: t.find((x) => x !== query.tag) })} />
        ) : null}
      </div>
    </>
  );
}

/** Images chosen for a post, with what each shows, before they're uploaded. */
export function ImagePicker({ images, onChange }: { images: (ImageToPost & { url: string })[]; onChange: (i: (ImageToPost & { url: string })[]) => void }) {
  return (
    <div className="field">
      <span className="field-label">Images</span>
      <div className="images" style={{ marginTop: 0 }}>
        {images.map((img, i) => (
          <div key={img.url} className="stack" style={{ gap: 8 }}>
            <img className="sq" src={img.url} alt={img.alt} />
            <Field label={`What image ${i + 1} shows`} hideLabel value={img.alt} placeholder="What it shows" onChange={(alt) => onChange(images.map((x, j) => (j === i ? { ...x, alt } : x)))} />
            <Button variant="danger" size="small" onPress={() => onChange(images.filter((_, j) => j !== i))}>Remove</Button>
          </div>
        ))}
      </div>
      {images.length < 4 ? (
        <div>
          <ImageChooser label="Add an image" multiple onChoose={(chosen) => {
            const files = chosen.filter((f) => (f.size <= 1_000_000 ? true : (toast(`${f.name} is over 1 MB`), false)));
            onChange([...images, ...files.slice(0, 4 - images.length).map((file) => ({ file, alt: "", url: URL.createObjectURL(file) }))]);
          }} />
        </div>
      ) : null}
    </div>
  );
}

/** Posting a discussion: a title, what you'd like, its tags and images. */
export function ComposeSheet({ view, onClose }: { view: SpaceView; onClose: () => void }) {
  const signed = useSignedIn();
  const [title, setTitle] = useState("");
  const [body, setBody] = useState("");
  const [tags, setTags] = useState<string[]>([]);
  const [images, setImages] = useState<(ImageToPost & { url: string })[]>([]);
  const [busy, run] = useAction();
  const post = () =>
    run(async () => {
      if (signed.state !== "in") return;
      const made = await postDiscussion(signed.session, refOf(view.space), { title, body, tags, images });
      toast("Posted");
      refreshSoon();
      navigate(discussionPath(made.uri));
    });
  return (
    <Sheet
      title="New post"
      onClose={onClose}
      actions={<><Button variant="secondary" onPress={onClose}>Cancel</Button><Button variant="primary" busy={busy} disabled={!title.trim()} onPress={() => void post()}>Post</Button></>}
    >
      <Field label="Title" value={title} onChange={setTitle} placeholder="A short summary" maxLength={300} big />
      <Field label="Details" value={body} onChange={setBody} placeholder="What happened, or what would you like?" multiline maxLength={10000} />
      {view.space.value.tags?.length ? (
        <div className="field">
          <span className="field-label">Tags</span>
          <Chips label="Tags" options={view.space.value.tags.map((t) => ({ value: t.value, label: t.label }))} selected={tags} onChange={(t) => setTags(t.slice(0, 8))} />
        </div>
      ) : null}
      <ImagePicker images={images} onChange={setImages} />
    </Sheet>
  );
}

/** Note the visit to a space once a page, when signed in (the home page lists them). */
export function useVisit(view: SpaceView | undefined) {
  const signed = useSignedIn();
  useEffect(() => {
    if (signed.state === "in" && view) void recordVisit(signed.session, refOf(view.space)).catch(() => undefined);
  }, [signed, view]);
}

/** A space's board. */
export function SpacePage(props: { uri: string; sort?: string; tag?: string; group?: string; q?: string; compose: boolean; onSignIn: () => void }) {
  const me = useMe();
  // read side by side: the board doesn't wait for the space's header (it reads its moderation itself)
  const view = useData(`space:${props.uri}`, () => loadSpace(props.uri));
  const loaded = view.value;
  // (signing in reads it again with your votes: what was shown stays until then)
  const board = useData(`board:${props.uri}:${me ?? ""}`, () => loadBoard(props.uri, me), { keep: true });
  useVisit(loaded);
  const query: BoardQuery = { sort: (SORTS.find((s) => s.value === props.sort)?.value ?? "trending") as Sort, group: props.group, tag: props.tag, q: props.q };
  const items = board.value;
  const counts = useMemo(() => new Map(STATE_GROUPS.map((g) => [g.label, items?.filter((i) => g.states.includes(i.status)).length ?? 0])), [items]);
  const shown = useMemo(() => {
    if (!items) return undefined;
    const states = STATE_GROUPS.find((g) => g.label === query.group)?.states;
    return sortItems(filterItems(items, { tag: query.tag, states, query: query.q }), query.sort);
  }, [items, query.group, query.tag, query.q, query.sort]);
  const compose = () => (me ? navigate(`${location.pathname}?compose=1`, { scroll: false }) : props.onSignIn());
  if (view.error && !loaded) return <main className="page"><Empty icon="space" title="This space couldn't be read">{view.error.message}</Empty></main>;
  if (!view.loading && !loaded) return <main className="page"><Empty icon="space" title="This space is gone" /></main>;
  return (
    <main className="page">
      <Title text={loaded?.space.value.name} />
      {loaded ? <SpaceHeader view={loaded} count={items?.length} onCompose={compose} /> : <div className="space-head"><span className="space-icon sq large" /><div className="stack" style={{ flex: 1 }}><div className="skeleton" style={{ height: 44, width: "40%" }} /></div></div>}
      {loaded ? <Toolbar view={loaded} query={query} counts={counts} /> : <div style={{ height: 120 }} />}
      {!shown || !loaded ? (
        <SkeletonRows />
      ) : shown.length ? (
        <div className="list sq">{shown.map((i) => <DiscussionRow key={i.discussion.uri} item={i} view={loaded} onSignIn={props.onSignIn} />)}</div>
      ) : (
        <div className="card sq">
          <Empty icon="bubble" title={items?.length ? "Nothing matches" : "No posts yet"}>
            {items?.length ? null : <Button variant="primary" onPress={compose}>Be the first</Button>}
          </Empty>
        </div>
      )}
      {props.compose && loaded && me ? <ComposeSheet view={loaded} onClose={() => navigate(location.pathname, { replace: true, scroll: false })} /> : null}
    </main>
  );
}

used by