app

tcmts.examples.userinput.app · #ssxgqhxs · tsx file

// tcm: namespace userinput.app
// The app: its pages, by path, in a frame with the navigation bar. The path is the state (a space is
// /s/<owner>/<key>, a discussion /d/<author>/<key>), so links, the back button and reloading all work, and the
// server renders the right page's first paint.
import assert from "node:assert/strict";
import { useState } from "react";
import { RouterProvider } from "react-aria-components";
import { href, match, navigate, Stylesheet, useAppLinks, useData, useLocation } from "#codebase/userinput/lib/react";
import { atUri, checkedAt, loadNotifications, NSID } from "#codebase/userinput";
import { signInWithPassword, signOut, startSignIn, setSignedIn, useSessionStart, useSignedIn } from "#codebase/userinput/account";
import { Avatar, Button, discussionPath, Field, HandleField, Icon, IconButton, Link, Menu, Sheet, spacePath, Spinner, styles, Toasts, useProfile } from "#codebase/userinput/design";
import { DiscussionPage, HomePage, NewSpacePage, NotFoundPage, NotificationsPage, ProfilePage, SpacePage, SpaceSettingsPage } from "#codebase/userinput/pages";

/** A page of the app, as its path says. */
export type Route =
  | { page: "home" }
  | { page: "space"; uri: string; sort?: string; tag?: string; group?: string; q?: string; compose: boolean }
  | { page: "space-settings"; uri: string }
  | { page: "discussion"; uri: string }
  | { page: "notifications" }
  | { page: "profile"; who: string }
  | { page: "new-space" }
  | { page: "not-found"; path: string };

/**
 * The page a path (and query) is.
 *
 * ```ts
 * parseRoute("/s/did:plc:abc/3kx", "?sort=new&tag=bug")
 * parseRoute("/d/did:plc:abc/3ky", "")
 * ```
 */
export function parseRoute(pathname: string, search: string): Route {
  const q = new URLSearchParams(search);
  let m: Record<string, string> | undefined;
  if (match("/", pathname)) return { page: "home" };
  if ((m = match("/s/:owner/:key", pathname)))
    return { page: "space", uri: atUri(m.owner!, NSID.space, m.key!), sort: q.get("sort") ?? undefined, tag: q.get("tag") ?? undefined, group: q.get("group") ?? undefined, q: q.get("q") ?? undefined, compose: q.get("compose") === "1" };
  if ((m = match("/s/:owner/:key/settings", pathname))) return { page: "space-settings", uri: atUri(m.owner!, NSID.space, m.key!) };
  if ((m = match("/d/:author/:key", pathname))) return { page: "discussion", uri: atUri(m.author!, NSID.discussion, m.key!) };
  if (match("/notifications", pathname)) return { page: "notifications" };
  if ((m = match("/profile/:who", pathname))) return { page: "profile", who: m.who! };
  if (match("/new", pathname)) return { page: "new-space" };
  return { page: "not-found", path: pathname };
}

/** The page the browser is on; the component re-renders when it changes. On the server, the page being rendered. */
export function useRoute(): Route {
  const at = useLocation();
  return parseRoute(at.path, at.query.toString());
}

/** How many notifications are new: since the person last looked. */
export function useUnread(me: string | undefined): number {
  const unread = useData(me ? `unread:${me}` : undefined, async () => {
    const [all, seen] = await Promise.all([loadNotifications(me!), checkedAt(me!)]);
    return all.filter((x) => !seen || x.at > seen).length;
  });
  return unread.value ?? 0;
}

/** Signing in: with your handle (the browser goes to your server to approve it), or with an app password. */
export function SignInSheet({ onClose }: { onClose: () => void }) {
  const [handle, setHandle] = useState("");
  const [password, setPassword] = useState("");
  const [withPassword, setWithPassword] = useState(false);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");
  const submit = async () => {
    setBusy(true);
    setError("");
    try {
      if (withPassword) {
        setSignedIn({ state: "in", session: await signInWithPassword(handle, password) });
        onClose();
      } else await startSignIn(handle);
    } catch (e) {
      setError((e as Error).message);
      setBusy(false);
    }
  };
  return (
    <Sheet title="Sign in" onClose={onClose}>
      <form onSubmit={(e) => (e.preventDefault(), void submit())}>
        <p className="subhead" style={{ marginBottom: 28 }}>Use your Bluesky or AT Protocol account. Your posts stay in your own repository.</p>
        <HandleField label="Your handle" value={handle} onChange={setHandle} onPick={(p) => setHandle(p.handle)} />
        {withPassword ? <Field label="App password" type="password" value={password} onChange={setPassword} hint="Make one in your account's settings; with it, your handle or your account's email. It's kept for this tab only." /> : null}
        {error ? <p className="error-text">{error}</p> : null}
        <div className="sheet-actions" style={{ justifyContent: "space-between" }}>
          <Button variant="plain" onPress={() => setWithPassword(!withPassword)}>{withPassword ? "Sign in with your server" : "Use an app password"}</Button>
          <Button variant="primary" size="large" type="submit" busy={busy} disabled={!handle.trim() || (withPassword && !password)}>Continue</Button>
        </div>
      </form>
    </Sheet>
  );
}

/** The signed-in person, in the bar: their picture, which opens a menu (their page, a new space, signing out). */
export function AccountButton({ did }: { did: string }) {
  const p = useProfile(did);
  return (
    <Menu
      label={`Account: ${p?.displayName || p?.handle || did}`}
      face={<Avatar profile={p} size={32} />}
      items={[
        { label: "Your page", icon: "person", onSelect: () => navigate(`/profile/${p?.handle ?? did}`) },
        { label: "New space", icon: "space", onSelect: () => navigate("/new") },
        { label: "Sign out", icon: "signout", onSelect: () => void signOut(), danger: true },
      ]}
    />
  );
}

/** The bar across the top: the app, and who's signed in (or a way to sign in). */
export function NavBar({ onSignIn }: { onSignIn: () => void }) {
  const signed = useSignedIn();
  const me = signed.state === "in" ? signed.session.did : undefined;
  const unread = useUnread(me);
  return (
    <header className="bar">
      <div className="bar-inner">
        <Link to="/" className="brand" label="userinput, home">
          <span className="brand-mark sq"><Icon name="bubble" size={16} /></span>
          userinput
        </Link>
        <span className="bar-spacer" />
        {signed.state === "checking" ? <Spinner /> : me ? (
          <>
            <IconButton icon="bell" label="Notifications" badge={unread} onPress={() => navigate("/notifications")} />
            <AccountButton did={me} />
          </>
        ) : (
          <Button variant="primary" size="small" onPress={onSignIn}>Sign in</Button>
        )}
      </div>
    </header>
  );
}

/** The page the route is. */
export function Page({ route, onSignIn }: { route: Route; onSignIn: () => void }) {
  switch (route.page) {
    case "home":
      return <HomePage onSignIn={onSignIn} />;
    case "space":
      // (a space's page of its own: what one space showed isn't kept for another)
      return <SpacePage key={route.uri} uri={route.uri} sort={route.sort} tag={route.tag} group={route.group} q={route.q} compose={route.compose} onSignIn={onSignIn} />;
    case "space-settings":
      return <SpaceSettingsPage uri={route.uri} />;
    case "discussion":
      return <DiscussionPage key={route.uri} uri={route.uri} onSignIn={onSignIn} />;
    case "notifications":
      return <NotificationsPage />;
    case "profile":
      return <ProfilePage who={route.who} />;
    case "new-space":
      return <NewSpacePage onSignIn={onSignIn} />;
    default:
      return <NotFoundPage />;
  }
}

/** The app's icon: a speech bubble on a blue squircle. */
export const FAVICON = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0a84ff"/><stop offset="1" stop-color="#5e5ce6"/></linearGradient></defs><rect width="64" height="64" rx="18" fill="url(#g)"/><path d="M18 32a14 12 0 1 1 6 10l-6 2 2-5a12 12 0 0 1-2-7z" fill="none" stroke="#fff" stroke-width="4" stroke-linejoin="round"/></svg>')}`;

/** The app: the bar, the page, and the sign-in sheet when it's asked for. */
export function App() {
  useSessionStart();
  const route = useRoute();
  const [signingIn, setSigningIn] = useState(false);
  const signed = useSignedIn();
  // links follow it in the app, without reloading the page: React Aria's through its router, the rest (Markdown's)
  // through useAppLinks
  useAppLinks();
  return (
    <RouterProvider navigate={(to) => navigate(to)} useHref={href}>
      <Stylesheet css={styles} />
      <link rel="icon" href={FAVICON} />
      <NavBar onSignIn={() => setSigningIn(true)} />
      <Page route={route} onSignIn={() => setSigningIn(true)} />
      {signingIn && signed.state !== "in" ? <SignInSheet onClose={() => setSigningIn(false)} /> : null}
      {signed.state === "out" && signed.error ? <p className="page error-text">Signing in didn't finish: {signed.error}</p> : null}
      <Toasts />
    </RouterProvider>
  );
}

export const testRoutes = () => {
  assert.deepEqual(parseRoute("/", ""), { page: "home" });
  assert.deepEqual(parseRoute("/s/did:plc:abc/3kx", "?sort=new&tag=bug&compose=1"), {
    page: "space", uri: "at://did:plc:abc/app.userinput.space/3kx", sort: "new", tag: "bug", group: undefined, q: undefined, compose: true,
  });
  assert.deepEqual(parseRoute("/s/did:plc:abc/3kx/settings", ""), { page: "space-settings", uri: "at://did:plc:abc/app.userinput.space/3kx" });
  assert.deepEqual(parseRoute("/d/did:plc:abc/3ky", ""), { page: "discussion", uri: "at://did:plc:abc/app.userinput.discussion/3ky" });
  assert.deepEqual(parseRoute("/profile/alice.test", ""), { page: "profile", who: "alice.test" });
  assert.equal(parseRoute("/nope/x/y/z", "").page, "not-found");
  assert.equal(spacePath("at://did:plc:abc/app.userinput.space/3kx"), "/s/did:plc:abc/3kx");
  assert.equal(discussionPath("at://did:plc:abc/app.userinput.discussion/3ky"), "/d/did:plc:abc/3ky");
};

used by