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