discussion
tcmts.examples.userinput.discussion · #vh57egwe · tsx file
// tcm: namespace userinput.pages
// A discussion: what was posted, its official status, and its replies as threads; replying, editing what's yours,
// and, for the space's moderators, setting its status, pinning, hiding, locking and banning.
import { useState } from "react";
import { navigate, useData } from "#codebase/userinput/lib/react";
import { authorOf, canModerate, getRecord, imageUrl, loadDiscussion, refOf, STATES, stateLabel } from "#codebase/userinput";
import type { DiscussionView, ShownReply, State, Thread } from "#codebase/userinput";
import { ban, editPost, hidePost, postReply, setLocked, setPinned, setStatus, useMe, useSignedIn } from "#codebase/userinput/account";
import {
Button, Chips, ChoiceChips, discussionPath, Empty, Field, Icon, ImageGrid, Link, Menu, Person, Markdown, Sheet, Skeleton, spacePath, StatusPill,
refreshSoon, Tag, Time, Title, TONES, toast, useAction,
} from "#codebase/userinput/design";
import type { MenuItem } from "#codebase/userinput/design";
import { Votes } from "#codebase/userinput/pages";
/** Choosing a discussion's status: the state, a note saying why, and (a duplicate) of what. */
export function StatusSheet({ d, onClose, onDone }: { d: DiscussionView; onClose: () => void; onDone: () => void }) {
const signed = useSignedIn();
const [state, setState] = useState<State>(d.status.state);
const [note, setNote] = useState("");
const [duplicateOf, setDuplicateOf] = useState("");
const [busy, run] = useAction();
return (
<Sheet
title="Status"
onClose={onClose}
actions={<><Button onPress={onClose}>Cancel</Button><Button variant="primary" busy={busy} onPress={() => void run(async () => {
if (signed.state !== "in") return;
// (a link to it, as the address bar shows it, or its at:// URI: read, for the version it points at)
const dup = /\/d\/(did:[^/]+)\/([^/?#]+)/.exec(duplicateOf);
const other = state === "duplicate" && duplicateOf.trim() ? await getRecord(dup ? `at://${dup[1]}/app.userinput.discussion/${dup[2]}` : duplicateOf.trim()) : undefined;
if (state === "duplicate" && duplicateOf.trim() && !other) throw new Error("That discussion couldn't be found");
await setStatus(signed.session, refOf(d.discussion), state, note, other ? refOf(other) : undefined);
toast(`Marked ${stateLabel(state).toLowerCase()}`);
onDone();
})}>Save</Button></>}
>
<ChoiceChips label="State" options={STATES.map((s) => ({ value: s, label: stateLabel(s) }))} value={state} onChange={setState} />
{state === "duplicate" ? <Field label="Duplicate of" value={duplicateOf} onChange={setDuplicateOf} placeholder="A link to the other discussion" /> : null}
<Field label="Note" value={note} onChange={setNote} placeholder="What's happening, for the people following it" multiline maxLength={1500} />
</Sheet>
);
}
/** Editing your discussion (its title, details and tags) or reply (its text). The original stays; this goes beside it. */
export function EditSheet(props: { subject: { uri: string; cid: string }; title?: string; body: string; tags?: string[]; tagOptions?: { value: string; label: string }[]; onClose: () => void; onDone: () => void }) {
const signed = useSignedIn();
const [title, setTitle] = useState(props.title ?? "");
const [body, setBody] = useState(props.body);
const [tags, setTags] = useState(props.tags ?? []);
const [busy, run] = useAction();
const discussion = props.title !== undefined;
return (
<Sheet
title="Edit"
onClose={props.onClose}
actions={<><Button onPress={props.onClose}>Cancel</Button><Button variant="primary" busy={busy} disabled={discussion ? !title.trim() : !body.trim()} onPress={() => void run(async () => {
if (signed.state !== "in") return;
await editPost(signed.session, props.subject, discussion ? { title: title.trim(), body: body.trim(), tags } : { body: body.trim() });
toast("Saved");
props.onDone();
})}>Save</Button></>}
>
{discussion ? <Field label="Title" value={title} onChange={setTitle} maxLength={300} big /> : null}
<Field label={discussion ? "Details" : "Reply"} value={body} onChange={setBody} multiline maxLength={10000} />
{discussion && props.tagOptions?.length ? (
<div className="field">
<span className="field-label">Tags</span>
<Chips label="Tags" options={props.tagOptions} selected={tags} onChange={(t) => setTags(t.slice(0, 8))} />
</div>
) : null}
</Sheet>
);
}
/** Writing a reply: to the discussion, or under another reply. */
export function ReplyComposer({ d, parent, onDone, onCancel, onSignIn }: { d: DiscussionView; parent?: ShownReply; onDone: () => void; onCancel?: () => void; onSignIn: () => void }) {
const signed = useSignedIn();
const [body, setBody] = useState("");
const [busy, run] = useAction();
const me = signed.state === "in" ? signed.session.did : undefined;
if (d.locked) return <div className="banner sq"><Icon name="lock" />This discussion is locked.</div>;
if (me && d.view.moderation.banned.has(me)) return <div className="banner sq"><Icon name="ban" />You can't post in this space.</div>;
if (!me) return <div className="banner sq" style={{ justifyContent: "space-between" }}><span>Sign in to reply.</span><Button variant="primary" size="small" onPress={onSignIn}>Sign in</Button></div>;
return (
<div className="composer">
<Field label={parent ? "Reply to this" : "Reply"} hideLabel multiline value={body} placeholder={parent ? "Reply…" : "Add to the discussion…"} onChange={setBody} maxLength={10000} />
<div className="inline" style={{ justifyContent: "flex-end" }}>
{onCancel ? <Button variant="plain" onPress={onCancel}>Cancel</Button> : null}
<Button variant="primary" busy={busy} disabled={!body.trim()} onPress={() => void run(async () => {
if (signed.state !== "in") return;
await postReply(signed.session, refOf(d.discussion), body, parent ? refOf(parent) : undefined);
setBody("");
onDone();
})}>Reply</Button>
</div>
</div>
);
}
/** A reply: who and when, what it says, its votes, and replying to it, editing it (yours) or hiding it (moderators). */
export function ReplyView({ t, d, reload, onSignIn }: { t: Thread; d: DiscussionView; reload: () => void; onSignIn: () => void }) {
const signed = useSignedIn();
const me = signed.state === "in" ? signed.session.did : undefined;
const [replying, setReplying] = useState(false);
const [editing, setEditing] = useState(false);
const r = t.reply as ShownReply;
const author = authorOf(r.uri);
const mod = canModerate(d.view.authority, me);
const items: MenuItem[] = [
...(me === author ? [{ label: "Edit", icon: "edit", onSelect: () => setEditing(true) }] : []),
...(mod && me !== author ? [{ label: "Hide", icon: "hide", danger: true, onSelect: () => signed.state === "in" && void hidePost(signed.session, refOf(d.view.space), refOf(r)).then(() => (toast("Hidden"), reload())) }] : []),
];
return (
<div className="thread">
<div className="reply">
<div className="meta">
<Person did={author} size={26} />
{d.view.authority.owner === author ? <span className="tag">Owner</span> : d.view.authority.roles.has(author) ? <span className="tag">Moderator</span> : null}
<Time iso={r.value.createdAt} />
{r.editedAt ? <span title={new Date(r.editedAt).toLocaleString()}>· edited</span> : null}
</div>
<Markdown text={r.value.body} className="reply-body" />
<div className="reply-actions">
<Votes subject={refOf(r)} tally={d.votes.get(r.uri) ?? r.tally} onSignIn={onSignIn} horizontal />
{!d.locked ? <Button variant="plain" size="small" icon="bubble" onPress={() => (me ? setReplying(!replying) : onSignIn())}>Reply</Button> : null}
<Menu items={items} />
</div>
{replying ? <ReplyComposer d={d} parent={r} onSignIn={onSignIn} onCancel={() => setReplying(false)} onDone={() => (setReplying(false), reload())} /> : null}
</div>
{t.children.length ? <div className="reply-children">{t.children.map((c) => <ReplyView key={c.reply.uri} t={c} d={d} reload={reload} onSignIn={onSignIn} />)}</div> : null}
{editing ? <EditSheet subject={refOf(r)} body={r.value.body} onClose={() => setEditing(false)} onDone={() => (setEditing(false), reload())} /> : null}
</div>
);
}
/** A discussion's official status, with the moderator's note (and what it duplicates). */
export function StatusNote({ d }: { d: DiscussionView }) {
const s = d.status;
if (s.state === "open" && !s.note) return null;
return (
<div className="status-note sq" style={{ ["--tone" as string]: TONES[s.state] }}>
<div className="inline" style={{ gap: 10 }}>
<StatusPill state={s.state} />
{s.at ? <span className="caption"><Time iso={s.at} /></span> : null}
</div>
{s.note ? <Markdown text={s.note} className="reply-body" /> : null}
{s.duplicateOf ? <Link to={discussionPath(s.duplicateOf)} className="caption" >See the discussion this duplicates →</Link> : null}
</div>
);
}
/** What a moderator can do to a discussion. */
export function moderationItems(d: DiscussionView, session: Parameters<typeof setPinned>[0], open: (sheet: "status") => void, reload: () => void): MenuItem[] {
const pinned = d.view.moderation.pinned.has(d.discussion.uri);
const author = authorOf(d.discussion.uri);
const done = (msg: string) => () => (toast(msg), reload());
return [
{ label: "Set status", icon: "status", onSelect: () => open("status") },
{ label: pinned ? "Unpin" : "Pin to top", icon: "pin", onSelect: () => void setPinned(session, refOf(d.view.space), refOf(d.discussion), !pinned).then(done(pinned ? "Unpinned" : "Pinned")) },
{ label: d.locked ? "Unlock" : "Lock", icon: "lock", onSelect: () => void setLocked(session, refOf(d.discussion), !d.locked).then(done(d.locked ? "Unlocked" : "Locked")) },
{ label: "Hide", icon: "hide", danger: true, onSelect: () => void hidePost(session, refOf(d.view.space), refOf(d.discussion)).then(() => (toast("Hidden"), refreshSoon(), navigate(spacePath(d.view.space.uri)))) },
...(author !== d.view.authority.owner ? [{ label: "Ban author", icon: "ban", danger: true, onSelect: () => void ban(session, refOf(d.view.space), author).then(done("Banned")) }] : []),
];
}
/** A discussion's page. */
export function DiscussionPage({ uri, onSignIn }: { uri: string; onSignIn: () => void }) {
const signed = useSignedIn();
const me = useMe();
// (signing in reads it again with your votes: what was shown stays until then)
const loaded = useData(`discussion:${uri}:${me ?? ""}`, () => loadDiscussion(uri, me), { keep: true });
const [sheet, setSheet] = useState<"status" | "edit">();
// (what was posted shows up in the index a moment after it's written: then the page reads it again, in place)
const reload = refreshSoon;
if (loaded.loading && !loaded.stale)
return <main className="page narrow"><div className="stack"><Skeleton width="30%" /><Skeleton width="80%" height={44} /><Skeleton height={120} /></div></main>;
if (loaded.error && !loaded.value) return <main className="page narrow"><Empty icon="bubble" title="This discussion couldn't be read">{loaded.error.message}</Empty></main>;
const d = loaded.value;
if (!d) return <main className="page narrow"><Empty icon="bubble" title="This discussion is gone" /></main>;
const v = d.discussion.value;
const author = authorOf(d.discussion.uri);
const labels = new Map((d.view.space.value.tags ?? []).map((t) => [t.value, t.label]));
const hidden = d.view.moderation.hidden.has(d.discussion.uri) || d.view.moderation.banned.has(author);
const items: MenuItem[] = [
...(me === author ? [{ label: "Edit", icon: "edit", onSelect: () => setSheet("edit") }] : []),
...(signed.state === "in" && canModerate(d.view.authority, me) ? moderationItems(d, signed.session, setSheet, reload) : []),
];
return (
<main className="page narrow">
<Title text={v.title} />
<Link to={spacePath(d.view.space.uri)} className="btn sq plain" ><Icon name="back" />{d.view.space.value.name}</Link>
<article className="stack" style={{ gap: 20, marginTop: 20 }}>
{hidden ? <div className="banner sq"><Icon name="hide" />Hidden by the space's moderators.</div> : null}
<div className="inline">
<StatusPill state={d.status.state} />
{d.view.moderation.pinned.has(d.discussion.uri) ? <span className="tag">Pinned</span> : null}
{(v.tags ?? []).map((t) => <Tag key={t} label={labels.get(t) ?? t} />)}
</div>
<h1 className="large-title">{v.title}</h1>
<div className="meta">
<Person did={author} size={28} />
<Time iso={v.createdAt} />
{d.discussion.editedAt ? <span>· edited</span> : null}
</div>
{v.body ? <Markdown text={v.body} /> : null}
<ImageGrid images={(v.images ?? []).map((i) => ({ thumb: imageUrl(author, i.image.ref.$link, "card"), full: imageUrl(author, i.image.ref.$link, "full"), alt: i.alt ?? "" }))} />
<div className="inline" style={{ justifyContent: "space-between" }}>
<Votes subject={refOf(d.discussion)} tally={d.tally} onSignIn={onSignIn} horizontal />
<Menu items={items} />
</div>
<StatusNote d={d} />
</article>
<section className="section">
<h2 className="title" style={{ marginBottom: 20 }}>{d.replies ? `${d.replies} ${d.replies === 1 ? "reply" : "replies"}` : "Replies"}</h2>
<ReplyComposer d={d} onDone={reload} onSignIn={onSignIn} />
<div style={{ marginTop: 12 }}>{d.threads.map((t) => <ReplyView key={t.reply.uri} t={t} d={d} reload={reload} onSignIn={onSignIn} />)}</div>
{d.locked ? <div className="banner sq" style={{ marginTop: 16 }}><Icon name="lock" />Locked <Time iso={d.locked} />: later replies aren't shown.</div> : null}
</section>
{sheet === "status" ? <StatusSheet d={d} onClose={() => setSheet(undefined)} onDone={() => (setSheet(undefined), reload())} /> : null}
{sheet === "edit" ? (
<EditSheet subject={refOf(d.discussion)} title={v.title} body={v.body ?? ""} tags={v.tags} tagOptions={(d.view.space.value.tags ?? []).map((t) => ({ value: t.value, label: t.label }))} onClose={() => setSheet(undefined)} onDone={() => (setSheet(undefined), reload())} />
) : null}
</main>
);
}
used by