v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A user's profile.
|
||||
* Renders a user's profile page, which is also where the signed-in user edits
|
||||
* their own profile and settings.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -77,10 +78,9 @@ import { SubmitButton } from "../components/form/SubmitButton.tsx";
|
||||
import { useApiForm } from "../hooks/form/useApiForm.ts";
|
||||
|
||||
/**
|
||||
* Renders the own-profile action row: "Invite someone" then `children` (log
|
||||
* out). The invite button toggles a panel below the row holding a fresh
|
||||
* single-use link; the panel eases in like the inline editors and takes the
|
||||
* row's width.
|
||||
* Renders the "Invite someone" button, then `children`, the log out button.
|
||||
* Each opening of the panel creates a new single-use invite link, for
|
||||
* `/register`.
|
||||
*/
|
||||
function ProfileOwnActions({ children }: { children: React.ReactNode }) {
|
||||
const { authFetch } = useAuth();
|
||||
@@ -124,7 +124,8 @@ function ProfileOwnActions({ children }: { children: React.ReactNode }) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard denied (e.g. insecure context): the field is selectable.
|
||||
// The clipboard is denied outside a secure context. The link can still
|
||||
// be selected by hand.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +180,11 @@ function ProfileOwnActions({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A paginated list. */
|
||||
/**
|
||||
* A list of the profile, of which `page` pages are loaded. The profile holds
|
||||
* the first page only, since each has its own page for the rest, but the
|
||||
* followed users.
|
||||
*/
|
||||
interface PaginatedList<T> {
|
||||
items: T[];
|
||||
hasMore: boolean;
|
||||
@@ -187,12 +192,14 @@ interface PaginatedList<T> {
|
||||
loadingMore: boolean;
|
||||
}
|
||||
|
||||
/** Returns the first page of a list. */
|
||||
function initialList<T>(items: T[], hasMore: boolean): PaginatedList<T> {
|
||||
return { items, hasMore, page: 1, loadingMore: false };
|
||||
}
|
||||
|
||||
/** The profile's state: loading, failed, or loaded with its lists. */
|
||||
/**
|
||||
* The profile's user and its first page of dumps, upvoted dumps and playlists,
|
||||
* fetched together.
|
||||
*/
|
||||
type ProfileState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
@@ -204,7 +211,7 @@ type ProfileState =
|
||||
playlists: PaginatedList<Playlist>;
|
||||
};
|
||||
|
||||
/** The followed tab's state, null until it is opened. */
|
||||
/** Null until the "followed" tab is first opened, which fetches it. */
|
||||
type FollowedState =
|
||||
| null
|
||||
| { status: "loading" }
|
||||
@@ -215,16 +222,18 @@ type FollowedState =
|
||||
playlists: PaginatedList<Playlist>;
|
||||
};
|
||||
|
||||
/** The invitees tab's state, null until it is opened. */
|
||||
/** Null until the "invitees" tab is first opened, which fetches it. */
|
||||
type InviteTreeState =
|
||||
| null
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "loaded"; entries: Invitee[] };
|
||||
|
||||
/** An invitee, with those they invited. */
|
||||
type InviteTreeNode = Invitee & { children: InviteTreeNode[] };
|
||||
/** The profile's tabs. */
|
||||
/**
|
||||
* The profile's tabs, the `:profileTab` of `/users/:username/~/:profileTab`.
|
||||
* "dumps" is the default, at `/users/:username`.
|
||||
*/
|
||||
const PROFILE_TABS = [
|
||||
"dumps",
|
||||
"playlists",
|
||||
@@ -233,10 +242,12 @@ const PROFILE_TABS = [
|
||||
"settings",
|
||||
"manage",
|
||||
] as const;
|
||||
/** One of the {@link PROFILE_TABS}. */
|
||||
type ProfileTab = (typeof PROFILE_TABS)[number];
|
||||
|
||||
/** Returns the invitees of `parentId` among `entries`, each with theirs. */
|
||||
/**
|
||||
* Returns the invitees of `parentId` among `entries`, each with theirs. The
|
||||
* server answers the whole tree as a flat list.
|
||||
*/
|
||||
function buildInviteTree(
|
||||
entries: Invitee[],
|
||||
parentId: string,
|
||||
@@ -247,13 +258,13 @@ function buildInviteTree(
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `/users/:username` and `/users/:username/~/:profileTab`, for anyone:
|
||||
* the user's avatar, name, role and description, and their dumps, playlists,
|
||||
* follows and invitees, one tab each. On their own profile a user can change
|
||||
* their avatar, description and email, invite someone, and set the theme,
|
||||
* sounds, default feed tab and password. The manage tab changes another user's
|
||||
* role, with `user:manage`, or on one's own profile manages the categories,
|
||||
* with `category:manage`.
|
||||
* Renders `/users/:username` and `/users/:username/~/:profileTab`, for anyone.
|
||||
* Its tabs show the user's dumps and upvotes, playlists, follows and invitees,
|
||||
* and the lists follow the socket's events. On their own profile, users edit
|
||||
* their avatar, description and email, create invites, and get a "settings"
|
||||
* tab for their password and this browser's preferences. The "manage" tab
|
||||
* shows another user's role to those who hold `user:manage`, and one's own
|
||||
* categories to those who hold `category:manage`.
|
||||
*/
|
||||
export function UserPublicProfile(): React.JSX.Element {
|
||||
const { username } = useParams();
|
||||
@@ -301,20 +312,17 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
const profileUserId = state.status === "loaded" ? state.user.id : null;
|
||||
const isOwnProfile = me?.id === profileUserId;
|
||||
|
||||
// Active tab is driven by the `/~/<tab>` URL path (linkable / refresh-safe).
|
||||
// `settings` is own-profile only; `manage` requires the user:manage permission
|
||||
// on someone else's profile. Invalid tabs fall back to "dumps".
|
||||
const canManageUsers = can(me, "user:manage");
|
||||
const canManageCategories = can(me, "category:manage");
|
||||
// The "manage" tab is an admin/moderation area. On someone else's profile it
|
||||
// holds per-user tools (role management); on your own it holds global tools
|
||||
// (category management, with more to come).
|
||||
// "manage" holds tools for another user on their profile, and site-wide
|
||||
// tools on one's own.
|
||||
const showManageTab = isOwnProfile ? canManageCategories : canManageUsers;
|
||||
const availableTabs: ProfileTab[] = PROFILE_TABS.filter((t) => {
|
||||
if (t === "settings") return isOwnProfile;
|
||||
if (t === "manage") return showManageTab;
|
||||
return true;
|
||||
});
|
||||
// A tab not available here, as while the profile loads, shows "dumps".
|
||||
const [tab, setTab] = useTabParam<ProfileTab>(availableTabs, "dumps");
|
||||
|
||||
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||
@@ -338,8 +346,8 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
skipReinsert: true,
|
||||
});
|
||||
|
||||
// Dump IDs removed due to vote withdrawal — must not be re-inserted on
|
||||
// a future dump_updated event (that would only be for private→public transitions).
|
||||
// A withdrawn vote leaves its dump in the list, for UpvotedDumpList to fade
|
||||
// out. If the dump then turns private and public again, it must not return.
|
||||
const withdrawnVoteIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const setVotes = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||
@@ -383,12 +391,10 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
skipReinsert: true,
|
||||
});
|
||||
|
||||
// Update profile user when they edit their own profile
|
||||
useEffect(() => {
|
||||
if (!lastUserEvent) return;
|
||||
const { user } = lastUserEvent;
|
||||
// Subscribing to the WS-pushed user-update stream — setState here
|
||||
// mirrors external state into local state, no synchronous alternative.
|
||||
// Mirrors a socket event into state, which can only happen in an effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded" || s.user.id !== user.id) return s;
|
||||
@@ -423,8 +429,8 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
setInviteTreeState(null);
|
||||
}
|
||||
|
||||
// Refs can't be written during render — reset in an effect instead, which
|
||||
// still runs (and clears the ref) before the vote-diffing effect below.
|
||||
// A ref can't be written during render. This effect runs before the one
|
||||
// below that diffs the votes, so it still resets in time.
|
||||
useEffect(() => {
|
||||
prevMyVotesRef.current = null;
|
||||
}, [username]);
|
||||
@@ -436,7 +442,8 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
const allCached = cachedDumps && cachedVotes && cachedPlaylists;
|
||||
|
||||
if (allCached) {
|
||||
// Only fetch the user object (lightweight, always fresh)
|
||||
// The lists come from this tab's session cache, as on Back. The user is
|
||||
// always fetched afresh.
|
||||
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
@@ -548,7 +555,8 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
return () => controller.abort();
|
||||
}, [username, cachedDumps, cachedVotes, cachedPlaylists, token]);
|
||||
|
||||
// Own profile: prepend dumps newly voted by the user to the preview list
|
||||
// On one's own profile, a vote on one's own dump shows in the upvoted list
|
||||
// at once, from the optimistic `myVotes`, before the socket's vote event.
|
||||
useEffect(() => {
|
||||
if (!profileUserId || me?.id !== profileUserId) return;
|
||||
if (prevMyVotesRef.current === null) {
|
||||
@@ -571,15 +579,15 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
prevMyVotesRef.current = new Set(myVotes);
|
||||
}, [myVotes, me, profileUserId]);
|
||||
|
||||
// Real-time upvoted list sync via WS vote events
|
||||
// Votes by the profile's user, from any browser, update the upvoted list.
|
||||
useEffect(() => {
|
||||
if (!lastVoteEvent || !profileUserId) return;
|
||||
const { dumpId, voterId, action } = lastVoteEvent;
|
||||
if (voterId !== profileUserId) return;
|
||||
|
||||
if (action === "remove") {
|
||||
// Keep dump in state.votes.items as a ghost — UpvotedDumpList drives
|
||||
// its own votedIds + fading state and will animate the removal.
|
||||
// The dump stays in the list: UpvotedDumpList fades it out, then hides
|
||||
// it.
|
||||
withdrawnVoteIdsRef.current.add(dumpId);
|
||||
} else {
|
||||
withdrawnVoteIdsRef.current.delete(dumpId);
|
||||
@@ -592,12 +600,11 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
if (s.status !== "loaded") return s;
|
||||
const idx = s.votes.items.findIndex((d) => d.id === dumpId);
|
||||
if (idx !== -1) {
|
||||
// Ghost re-voted: update in-place.
|
||||
// A withdrawn vote cast again, before its dump was reloaded.
|
||||
const items = [...s.votes.items];
|
||||
items[idx] = dump;
|
||||
return { ...s, votes: { ...s.votes, items } };
|
||||
}
|
||||
// First-time vote: prepend.
|
||||
return {
|
||||
...s,
|
||||
votes: { ...s.votes, items: [dump, ...s.votes.items] },
|
||||
@@ -608,7 +615,7 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
}
|
||||
}, [lastVoteEvent, profileUserId]);
|
||||
|
||||
// Save scroll position + loaded state to sessionStorage on scroll
|
||||
// Saves the lists and the scroll position, so that Back restores both.
|
||||
useEffect(() => {
|
||||
if (state.status !== "loaded") return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
@@ -634,8 +641,8 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
};
|
||||
}, [state, saveDumps, saveVotes, savePlaylists]);
|
||||
|
||||
// Keep the playlists cache current whenever the list changes (e.g. via WS),
|
||||
// so a page refresh restores the up-to-date list rather than a stale snapshot.
|
||||
// The playlists also change from the socket without a scroll, and the cache
|
||||
// must not restore a stale list.
|
||||
const playlistFeed = state.status === "loaded" ? state.playlists : null;
|
||||
useEffect(() => {
|
||||
if (!playlistFeed) return;
|
||||
@@ -647,13 +654,13 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
);
|
||||
}, [playlistFeed, savePlaylists]);
|
||||
|
||||
// Lazy-load followed users + playlists when the Followed tab is first opened
|
||||
useEffect(() => {
|
||||
if (
|
||||
tab !== "followed" || followedState !== null || state.status !== "loaded"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Deferred, since the lint forbids a setState in an effect's body.
|
||||
queueMicrotask(() => setFollowedState({ status: "loading" }));
|
||||
const controller = new AbortController();
|
||||
Promise.all([
|
||||
@@ -691,12 +698,11 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
setFollowedState({ status: "error", error: friendlyFetchError(err) });
|
||||
});
|
||||
return () => controller.abort();
|
||||
// followedState intentionally omitted: it's a read-once guard, not a trigger.
|
||||
// Including it would abort the in-flight fetch when state changes to "loading".
|
||||
// followedState only guards the first fetch. As a dependency, its change
|
||||
// to "loading" would abort that fetch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab, state.status, username]);
|
||||
|
||||
// Lazy-load invite tree when the Invitees tab is first opened
|
||||
useEffect(() => {
|
||||
if (
|
||||
tab !== "invitees" || inviteTreeState !== null ||
|
||||
@@ -722,12 +728,11 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
setInviteTreeState({ status: "error", error: friendlyFetchError(err) });
|
||||
});
|
||||
return () => controller.abort();
|
||||
// inviteTreeState intentionally omitted — same read-once guard pattern.
|
||||
// inviteTreeState only guards the first fetch, as followedState above.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab, state.status, username]);
|
||||
|
||||
// There's no page of its own for who someone follows, so the list grows
|
||||
// here.
|
||||
// The followed users have no page of their own, so the list grows here.
|
||||
const loadMoreFollowedUsers = () => {
|
||||
if (
|
||||
followedState?.status !== "loaded" || followedState.users.loadingMore
|
||||
@@ -755,7 +760,7 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
.catch(() => setUsers((u) => ({ ...u, loadingMore: false })));
|
||||
};
|
||||
|
||||
// Restore scroll position after cache restoration
|
||||
// Before paint, so the restored page doesn't flash at the top.
|
||||
const scrollRestored = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
if (cachedDumps?.scrollY == null || scrollRestored.current) return;
|
||||
@@ -972,8 +977,8 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
onClick={isOwnProfile
|
||||
? () => setDescEditing(true)
|
||||
: undefined}
|
||||
// Reachable from the keyboard too; not a button, so the
|
||||
// markdown inside keeps its own structure.
|
||||
// Focusable rather than a button, so the Markdown inside
|
||||
// keeps its own structure and links.
|
||||
tabIndex={isOwnProfile ? 0 : undefined}
|
||||
title={isOwnProfile ? t`Edit description` : undefined}
|
||||
onKeyDown={isOwnProfile
|
||||
@@ -1451,9 +1456,10 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plain dump list ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Renders a list of dump cards. */
|
||||
/**
|
||||
* Renders the first page of the user's dumps, with a link to all of them. The
|
||||
* owner also gets a "New dump" button.
|
||||
*/
|
||||
function DumpList(
|
||||
{
|
||||
title,
|
||||
@@ -1528,9 +1534,10 @@ function DumpList(
|
||||
);
|
||||
}
|
||||
|
||||
// ── Upvoted list: fades items out when votes are removed ─────────────────────
|
||||
|
||||
/** Renders the upvoted list, fading items out when their votes are removed. */
|
||||
/**
|
||||
* Renders the first page of the dumps the user upvoted. A dump whose vote is
|
||||
* withdrawn fades out, and comes back if the vote is cast again meanwhile.
|
||||
*/
|
||||
function UpvotedDumpList(
|
||||
{
|
||||
title,
|
||||
@@ -1559,26 +1566,22 @@ function UpvotedDumpList(
|
||||
const { myVotes: wsMyVotes, lastVoteEvent } = useWS();
|
||||
const { fading, startFading, cancelFading } = useFading();
|
||||
|
||||
// votedIds is managed locally so setVotedIds + startFading/cancelFading can
|
||||
// be called in the same effect body — guaranteeing a single render where the
|
||||
// dump is always in visibleDumps (with or without fading class). This prevents
|
||||
// the DOM node from being unmounted/remounted, which would break CSS transitions.
|
||||
// Held here rather than derived, so that a dump leaves `votedIds` and starts
|
||||
// fading in the same render. In between, the card would unmount and its
|
||||
// transition break.
|
||||
const [votedIds, setVotedIds] = useState(() =>
|
||||
new Set(dumps.map((d) => d.id))
|
||||
);
|
||||
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||
|
||||
// Own profile: sync votedIds with myVotes; start/cancel fading in same batch.
|
||||
// setVotedIds and startFading/cancelFading must be called together synchronously
|
||||
// in the same effect to guarantee a single render where the DOM node isn't
|
||||
// unmounted — converting to render-phase isn't possible because startFading/
|
||||
// cancelFading are themselves setState calls that can't run during render.
|
||||
// On one's own profile, votes follow `myVotes`, which changes at once on a
|
||||
// vote here. This must be an effect, since startFading and cancelFading set
|
||||
// state too.
|
||||
useEffect(() => {
|
||||
if (!profileUserId || !isOwnProfile) return;
|
||||
if (prevMyVotesRef.current === null) {
|
||||
// Only a baseline: the list as loaded stands. myVotes may still be the
|
||||
// empty set from before the socket's welcome, and taking it as the
|
||||
// truth would hide every dump.
|
||||
// Only a baseline. myVotes may still be empty, from before the socket's
|
||||
// welcome, and would hide every dump.
|
||||
prevMyVotesRef.current = new Set(wsMyVotes);
|
||||
return;
|
||||
}
|
||||
@@ -1594,16 +1597,13 @@ function UpvotedDumpList(
|
||||
prevMyVotesRef.current = new Set(wsMyVotes);
|
||||
}, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]);
|
||||
|
||||
// Non-own profile: sync votedIds with WS vote events for the profile user.
|
||||
// Same constraint as above: setVotedIds and startFading/cancelFading must
|
||||
// fire together so the DOM node stays mounted throughout the transition.
|
||||
// On another's profile, votes follow the socket's vote events.
|
||||
useEffect(() => {
|
||||
if (!lastVoteEvent || !profileUserId || isOwnProfile) return;
|
||||
const { dumpId, voterId, action } = lastVoteEvent;
|
||||
if (voterId !== profileUserId) return;
|
||||
if (action === "remove") {
|
||||
// setVotedIds and startFading must fire together to avoid a render with
|
||||
// stale votedIds between the two updates.
|
||||
// Both updates must land in one render, as above.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setVotedIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -1666,9 +1666,7 @@ function UpvotedDumpList(
|
||||
);
|
||||
}
|
||||
|
||||
// ── Invite tree ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Renders the invite tree. */
|
||||
/** Renders the invite tree, each invitee nested under who invited them. */
|
||||
function InviteTreeList({ nodes }: { nodes: InviteTreeNode[] }) {
|
||||
return (
|
||||
<ul className="invite-tree">
|
||||
@@ -1693,9 +1691,7 @@ function InviteTreeList({ nodes }: { nodes: InviteTreeNode[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Followed user card ───────────────────────────────────────────────────────
|
||||
|
||||
/** Renders a followed user. */
|
||||
/** Renders a followed user, with a button to follow or unfollow them. */
|
||||
function FollowedUserCard({ user }: { user: PublicUser }) {
|
||||
return (
|
||||
<li className="followed-user-card">
|
||||
@@ -1717,7 +1713,6 @@ function FollowedUserCard({ user }: { user: PublicUser }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The roles, with their labels. */
|
||||
const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [
|
||||
{ value: "user", label: <Trans>User</Trans> },
|
||||
{ value: "moderator", label: <Trans>Moderator</Trans> },
|
||||
@@ -1725,8 +1720,8 @@ const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders the role badge shown next to the username. The default "user" role is
|
||||
* implicit, so only elevated roles get a chip to avoid labelling every profile.
|
||||
* Renders the role badge next to the username, for moderators and admins
|
||||
* only.
|
||||
*/
|
||||
function RoleChip({ role }: { role: Role }) {
|
||||
if (role === "user") return null;
|
||||
@@ -1738,7 +1733,6 @@ function RoleChip({ role }: { role: Role }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The props of {@link RoleSwitcher}. */
|
||||
interface RoleSwitcherProps {
|
||||
userId: string;
|
||||
currentRole: Role;
|
||||
@@ -1746,8 +1740,8 @@ interface RoleSwitcherProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the segmented role control (matches the dump-visibility toggle
|
||||
* style), gated behind the user:manage permission by its caller.
|
||||
* Renders the control that sets another user's role, which its caller shows
|
||||
* only to those who hold `user:manage`. The server checks it again.
|
||||
*/
|
||||
function RoleSwitcher({ userId, currentRole, onSaved }: RoleSwitcherProps) {
|
||||
const { authFetch } = useAuth();
|
||||
@@ -1801,14 +1795,16 @@ function RoleSwitcher({ userId, currentRole, onSaved }: RoleSwitcherProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The props of {@link EmailEditor}. */
|
||||
interface EmailEditorProps {
|
||||
initialEmail: string;
|
||||
onCancel: () => void;
|
||||
onSaved: (email: string) => void;
|
||||
}
|
||||
|
||||
/** Renders the inline editor of the user's email address. */
|
||||
/**
|
||||
* Renders the inline editor of the signed-in user's email, which only they
|
||||
* see.
|
||||
*/
|
||||
function EmailEditor({ initialEmail, onCancel, onSaved }: EmailEditorProps) {
|
||||
const { authFetch } = useAuth();
|
||||
const form = useApiForm<{ email: string }>({
|
||||
@@ -1866,14 +1862,16 @@ function EmailEditor({ initialEmail, onCancel, onSaved }: EmailEditorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The props of {@link DescriptionEditor}. */
|
||||
interface DescriptionEditorProps {
|
||||
initialDescription: string;
|
||||
onCancel: () => void;
|
||||
onSaved: (description: string | undefined) => void;
|
||||
}
|
||||
|
||||
/** Renders the inline editor of the user's description. */
|
||||
/**
|
||||
* Renders the inline editor of the signed-in user's description, in Markdown.
|
||||
* Other browsers see the change through the socket's `user_updated` event.
|
||||
*/
|
||||
function DescriptionEditor(
|
||||
{ initialDescription, onCancel, onSaved }: DescriptionEditorProps,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user