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