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