people
tcmts.examples.userinput.people · #vn5q3xpi · tsx file
// tcm: namespace userinput.pages
// People: your notifications, someone's page (their spaces and posts), and a space's settings, where its owner
// changes it and appoints moderators, and its moderators lift bans.
import { useEffect, useState } from "react";
import { Tab, TabList, TabPanel, Tabs } from "react-aria-components";
import { navigate, refresh, useData } from "#codebase/userinput/lib/react";
import {
authorOf, backlinks, checkedAt, getRecord, linkUri, loadNotifications, loadOwnSpaces, loadPostedBy, loadSpace, NSID, parseAtUri, resolveIdentity,
} from "#codebase/userinput";
import type { BanRecord, Notification, SpaceTag } from "#codebase/userinput";
import { addMember, markChecked, removeMember, saveSpace, unban, useMe, useSignedIn } from "#codebase/userinput/account";
import {
Avatar, Button, discussionPath, Empty, Field, HandleField, Icon, Link, Person, plainText, SkeletonRows, spacePath, StatusPill, Time,
refreshSoon, Title, toast, useAction, useProfile,
} from "#codebase/userinput/design";
import { IconPicker, SpaceGrid, SpaceIcon, TagsEditor } from "#codebase/userinput/pages";
/** A notification, as a row: who did what to which of your posts. */
export function NotificationRow({ n, unread }: { n: Notification; unread: boolean }) {
return (
<Link to={discussionPath(n.discussion.uri)} className="row">
<span style={{ width: 10, paddingTop: 10 }}>{unread ? <span style={{ display: "block", width: 10, height: 10, borderRadius: 5, background: "var(--accent)" }} /> : null}</span>
<div className="row-main">
<div className="meta">
<Person did={n.by} size={24} link={false} />
<span>{n.kind === "reply" ? "replied to" : "set the status of"}</span>
<span className="person-name">{n.discussion.value.title}</span>
<Time iso={n.at} />
</div>
{n.kind === "status" && n.state ? <div><StatusPill state={n.state} /></div> : null}
{n.text ? <div className="row-excerpt">{plainText(n.text)}</div> : null}
</div>
</Link>
);
}
/** Your notifications: replies to what you posted, and statuses set on it. Looking at them marks them seen. */
export function NotificationsPage() {
const signed = useSignedIn();
const me = useMe();
const all = useData(me ? `notifications:${me}` : undefined, async () => ({ list: await loadNotifications(me!), seen: await checkedAt(me!) }));
const read = !!all.value;
useEffect(() => {
// (and the bell's count with it)
if (signed.state === "in" && read) void markChecked(signed.session).then(() => refresh(`unread:${signed.session.did}`), () => undefined);
}, [read, signed]);
if (!me) return <main className="page narrow"><Empty icon="bell" title="Sign in to see your notifications" /></main>;
return (
<main className="page narrow">
<Title text="Notifications" />
<h1 className="large-title" style={{ marginBottom: 32 }}>Notifications</h1>
{!all.value ? (
<SkeletonRows />
) : all.value.list.length ? (
<div className="list sq">{all.value.list.map((n) => <NotificationRow key={n.uri} n={n} unread={!all.value!.seen || n.at > all.value!.seen} />)}</div>
) : (
<div className="card sq"><Empty icon="bell" title="Nothing yet">Replies to your posts, and their statuses, show up here.</Empty></div>
)}
</main>
);
}
/** Someone's page: who they are, the spaces they made, and what they've posted. */
export function ProfilePage({ who }: { who: string }) {
const id = useData(`identity:${who}`, () => resolveIdentity(who));
const did = id.value?.did;
const p = useProfile(did);
const spaces = useData(did ? `spaces:${did}` : undefined, () => loadOwnSpaces(did!));
const posts = useData(did ? `posts:${did}` : undefined, () => loadPostedBy(did!));
if (id.error) return <main className="page narrow"><Empty icon="person" title="Nobody by that name" /></main>;
return (
<main className="page narrow">
<Title text={p?.displayName || p?.handle || who} />
<header className="space-head" style={{ marginBottom: 36 }}>
<Avatar profile={p} size={96} />
<div className="stack" style={{ gap: 4 }}>
<h1 className="large-title" style={{ margin: 0 }}>{p?.displayName || p?.handle || who}</h1>
<span className="subhead">@{p?.handle ?? who}</span>
</div>
</header>
<Tabs>
<TabList className="segmented sq" aria-label="Show">
<Tab id="posts" className="segment sq">Posts</Tab>
<Tab id="spaces" className="segment sq">Spaces</Tab>
</TabList>
<TabPanel id="posts" style={{ marginTop: 24 }}>
{!posts.value ? (
<SkeletonRows />
) : posts.value.length ? (
<div className="list sq">
{posts.value.map((d) => (
<Link key={d.uri} to={discussionPath(d.uri)} className="row">
<div className="row-main">
<span className="row-title">{d.value.title}</span>
{d.value.body ? <span className="row-excerpt">{plainText(d.value.body)}</span> : null}
<span className="meta"><Time iso={d.value.createdAt} /></span>
</div>
<Icon name="chevron" />
</Link>
))}
</div>
) : (
<div className="card sq"><Empty icon="bubble" title="No posts" /></div>
)}
</TabPanel>
<TabPanel id="spaces" style={{ marginTop: 24 }}>
{spaces.value && !spaces.value.length ? <div className="card sq"><Empty icon="space" title="No spaces" /></div> : <SpaceGrid spaces={spaces.value} />}
</TabPanel>
</Tabs>
</main>
);
}
/** A space's details, as its owner changes them. */
export function SpaceDetails({ uri, onSaved }: { uri: string; onSaved: () => void }) {
const signed = useSignedIn();
const space = useData(`space:${uri}`, () => loadSpace(uri)).value?.space;
const [name, setName] = useState<string>();
const [description, setDescription] = useState<string>();
const [tags, setTags] = useState<SpaceTag[]>();
const [icon, setIcon] = useState<Blob>();
const [busy, run] = useAction();
if (!space) return <SkeletonRows rows={1} />;
return (
<div className="card sq">
<Field label="Name" value={name ?? space.value.name} onChange={setName} maxLength={100} big />
<Field label="What it's for" value={description ?? space.value.description ?? ""} onChange={setDescription} multiline maxLength={1500} />
<TagsEditor tags={tags ?? space.value.tags ?? []} onChange={setTags} />
<div className="inline"><SpaceIcon space={space} /><IconPicker file={icon} onChange={setIcon} /></div>
<div className="sheet-actions">
<Button variant="primary" busy={busy} onPress={() => void run(async () => {
if (signed.state !== "in") return;
await saveSpace(signed.session, { name: name ?? space.value.name, description: description ?? space.value.description ?? "", tags: tags ?? space.value.tags ?? [], icon: icon ?? space.value.icon }, parseAtUri(uri)!.rkey, space.value.createdAt);
toast("Saved");
refreshSoon();
onSaved();
})}>Save</Button>
</div>
</div>
);
}
/** A space's moderators: who they are, removing one, and appointing another by handle. */
export function Moderators({ uri }: { uri: string }) {
const signed = useSignedIn();
const view = useData(`space:${uri}`, () => loadSpace(uri));
const [handle, setHandle] = useState("");
const [busy, run] = useAction();
const roles = view.value ? [...view.value.authority.roles] : [];
return (
<div className="card sq stack">
{roles.length ? roles.map(([did, role]) => (
<div key={did} className="inline" style={{ justifyContent: "space-between" }}>
<span className="inline"><Person did={did} size={32} /><span className="tag">{role === "admin" ? "Admin" : "Moderator"}</span></span>
<Button variant="danger" size="small" onPress={() => void run(async () => {
if (signed.state !== "in") return;
await removeMember(signed.session, uri, did);
toast("Removed");
refreshSoon();
})}>Remove</Button>
</div>
)) : <span className="muted">Just you, so far.</span>}
<HandleField label="Their handle" value={handle} onChange={setHandle} onPick={(p) => setHandle(p.handle)} />
<div className="inline" style={{ justifyContent: "flex-end" }}>
<Button variant="primary" busy={busy} disabled={!handle.trim()} onPress={() => void run(async () => {
if (signed.state !== "in" || !view.value) return;
const who = await resolveIdentity(handle.trim().replace(/^@/, ""));
await addMember(signed.session, { uri, cid: view.value.space.cid }, who.did, "moderator");
toast(`${who.handle} can moderate now`);
setHandle("");
refreshSoon();
})}>Add moderator</Button>
</div>
</div>
);
}
/** The people banned from a space, and lifting a ban. */
export function Bans({ uri }: { uri: string }) {
const signed = useSignedIn();
const bans = useData(`bans:${uri}`, async () => {
const view = await loadSpace(uri);
if (!view) return [];
const links = await backlinks(uri, NSID.ban, "space.uri", { dids: view.moderators });
const records = await Promise.all(links.map((l) => getRecord<BanRecord>(linkUri(l))));
return records.filter(Boolean).map((r) => ({ did: r!.value.subject, by: authorOf(r!.uri), at: r!.value.createdAt }));
});
const [, run] = useAction();
if (!bans.value) return <SkeletonRows rows={1} />;
return (
<div className="card sq stack">
{bans.value.length ? bans.value.map((b) => (
<div key={b.did} className="inline" style={{ justifyContent: "space-between" }}>
<span className="inline"><Person did={b.did} size={32} /><span className="caption">banned <Time iso={b.at} /></span></span>
{signed.state === "in" && b.by === signed.session.did ? (
<Button size="small" onPress={() => void run(async () => {
if (signed.state !== "in") return;
await unban(signed.session, uri, b.did);
toast("Ban lifted");
refreshSoon();
})}>Lift ban</Button>
) : null}
</div>
)) : <span className="muted">Nobody's banned.</span>}
</div>
);
}
/** A space's settings: its details and moderators (its owner), and its bans (its moderators). */
export function SpaceSettingsPage({ uri }: { uri: string }) {
const me = useMe();
const owner = authorOf(uri);
const view = useData(`space:${uri}`, () => loadSpace(uri));
const name = view.value?.space.value.name;
const allowed = view.value && me && (me === owner || view.value.authority.roles.has(me));
if (!view.loading && !allowed) return <main className="page narrow"><Empty icon="lock" title="Only the space's moderators can change it" /></main>;
return (
<main className="page narrow">
<Link to={spacePath(uri)} className="btn sq plain"><Icon name="back" />{name ?? "Back"}</Link>
<Title text={`Settings · ${name ?? "space"}`} />
<h1 className="large-title" style={{ margin: "20px 0 36px" }}>Settings</h1>
{me === owner ? (
<>
<section className="stack"><h2 className="headline">Details</h2><SpaceDetails uri={uri} onSaved={() => navigate(spacePath(uri))} /></section>
<section className="section stack"><h2 className="headline">Moderators</h2><Moderators uri={uri} /></section>
</>
) : null}
<section className="section stack"><h2 className="headline">Banned</h2><Bans uri={uri} /></section>
</main>
);
}
used by