Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/Icon/nucleus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import shieldCheck from "@worldcoin/nucleus/icons/shield-check-regular.svg";
import shieldHalf from "@worldcoin/nucleus/icons/shield-half-solid.svg";
import smartphone from "@worldcoin/nucleus/icons/smartphone-regular.svg";
import text from "@worldcoin/nucleus/icons/text-solid.svg";
import trash from "@worldcoin/nucleus/icons/trash-solid.svg";
import warningTriangle from "@worldcoin/nucleus/icons/warning-triangle-regular.svg";
import worldcoin from "@worldcoin/nucleus/icons/worldcoin-regular.svg";
import xmarkCircle from "@worldcoin/nucleus/icons/xmark-circle-regular.svg";
Expand Down Expand Up @@ -68,6 +69,7 @@ export const NUCLEUS_ICONS = {
"shield-half": asset(shieldHalf),
smartphone: asset(smartphone),
text: asset(text),
trash: asset(trash),
"warning-triangle": asset(warningTriangle),
worldcoin: asset(worldcoin),
xmark: asset(xmark),
Expand Down
84 changes: 45 additions & 39 deletions src/components/Row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,53 +13,59 @@ export function Row(props: {
leading?: ReactNode;
detail?: string;
trailing?: ReactNode;
/** Controls rendered beside the row, outside its button (e.g. a remove action). */
actions?: ReactNode;
destructive?: boolean;
divider?: boolean;
disabled?: boolean;
onClick?: () => void;
}) {
return (
<button
type="button"
onClick={props.onClick}
disabled={!!props.disabled || !props.onClick}
className={cn(
"flex w-full items-center gap-3 py-4 text-left outline-none transition-press duration-100 ease-press active:opacity-70",
{ "border-b border-stroke-tertiary": props.divider },
)}
<div
className={cn("group flex items-center gap-2", {
"border-b border-stroke-tertiary": props.divider,
})}
>
{props.leading}
{props.icon && (
<Icon
name={props.icon}
className={cn("size-6", {
"text-fg-secondary": !props.destructive,
"text-status-error": props.destructive,
})}
/>
)}
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span
className={cn("truncate text-s1", {
"text-fg-primary": !props.destructive,
"text-status-error": props.destructive,
})}
>
{props.title}
</span>
{props.detail && (
<span className="truncate text-b3 text-fg-tertiary">
{props.detail}
</span>
)}
</span>
{props.trailing ??
(props.onClick && (
<button
type="button"
onClick={props.onClick}
disabled={!!props.disabled || !props.onClick}
className="flex min-w-0 flex-1 items-center gap-3 py-4 text-left outline-none transition-press duration-100 ease-press active:opacity-70"
>
{props.leading}
{props.icon && (
<Icon
name="chevron-right"
className="size-6 text-fg-tertiary"
name={props.icon}
className={cn("size-6", {
"text-fg-secondary": !props.destructive,
"text-status-error": props.destructive,
})}
/>
))}
</button>
)}
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span
className={cn("truncate text-s1", {
"text-fg-primary": !props.destructive,
"text-status-error": props.destructive,
})}
>
{props.title}
</span>
{props.detail && (
<span className="truncate text-b3 text-fg-tertiary">
{props.detail}
</span>
)}
</span>
{props.trailing ??
(props.onClick && (
<Icon
name="chevron-right"
className="size-6 text-fg-tertiary"
/>
))}
</button>
{props.actions}
</div>
);
}
25 changes: 24 additions & 1 deletion src/components/SelectID/IDRow.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
import { Icon } from "@/components/Icon";
import { Row } from "@/components/Row";
import useIdentity from "@/hooks/useIdentity";
import { credentialsForIdentity } from "@/lib/credentials";
import type { Identity } from "@/types";
import Image from "next/image";
import { useRouter } from "next/router";
import { useMemo } from "react";
import toast from "react-hot-toast";

export default function IDRow(props: {
identity: Identity;
active?: boolean;
divider?: boolean;
}) {
const router = useRouter();
const { removeIdentity } = useIdentity();
const name = props.identity.meta.name;

const detail = useMemo(() => {
const credentials = credentialsForIdentity(props.identity);
if (credentials.length === 0) return "Unverified";
return credentials.map((credential) => credential.title).join(" · ");
}, [props.identity]);

const handleRemove = () => {
removeIdentity(props.identity.id);
toast(`Removed ${name}`);
};

return (
<li>
<Row
title={props.identity.meta.name}
title={name}
detail={detail}
leading={<IDEmoji identityID={props.identity.id} />}
divider={props.divider}
Expand All @@ -35,6 +44,20 @@ export default function IDRow(props: {
/>
) : undefined
}
actions={
// Revealed on hover or keyboard focus; always visible where there is no hover.
<button
type="button"
aria-label={`Remove ${name}`}
onClick={handleRemove}
className="flex size-9 shrink-0 items-center justify-center rounded-full text-fg-tertiary opacity-0 transition-press duration-100 ease-press hover:bg-surface-secondary hover:text-status-error focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stroke-primary/20 active:scale-[0.97] group-hover:opacity-100 [@media(hover:none)]:opacity-100"
>
<Icon
name="trash"
className="size-5"
/>
</button>
}
onClick={() => void router.push(`/id/${props.identity.id}`)}
/>
</li>
Expand Down
3 changes: 3 additions & 0 deletions src/hooks/useIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const getStore = (store: IdentityStore) => ({
insertIdentity: store.insertIdentity,
replaceIdentity: store.replaceIdentity,
reset: store.reset,
removeIdentity: store.removeIdentity,
});

const useIdentity = () => {
Expand All @@ -25,6 +26,7 @@ const useIdentity = () => {
insertIdentity,
replaceIdentity,
reset,
removeIdentity,
} = useIdentityStore(getStore);

const generateIdentityProofsIfNeeded = useCallback(
Expand Down Expand Up @@ -190,6 +192,7 @@ const useIdentity = () => {
? null
: activeIdentityID,
generateNextIdentity,
removeIdentity,
identities,
activeIdentity: activeIdentity,
resetIdentityStore,
Expand Down
14 changes: 13 additions & 1 deletion src/pages/select-id/index.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
import { NavBar, NavBarButton } from "@/components/NavBar";
import useIdentity from "@/hooks/useIdentity";
import dynamic from "next/dynamic";
import { useRouter } from "next/router";

const DynamicList = dynamic(() => import("@/components/SelectID/IDsList"), {
ssr: false,
});

export default function Select() {
const { generateNextIdentity } = useIdentity();
const router = useRouter();
const { activeIdentityID, generateNextIdentity } = useIdentity();

return (
<div className="flex min-h-0 flex-col gap-4 overflow-y-auto px-2 pb-6 scrollbar-hidden">
<NavBar
title="Test identities"
leading={
// Nothing to go back to until an identity is active.
activeIdentityID && (
<NavBarButton
icon="xmark"
label="Close"
onClick={() => void router.push(`/id/${activeIdentityID}`)}
/>
)
}
trailing={
<NavBarButton
icon="plus"
Expand Down
14 changes: 14 additions & 0 deletions src/stores/identityStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type IdentityStore = {
setActiveIdentityID: (id: string) => void;
insertIdentity: (identity: Identity) => void;
replaceIdentity: (identity: Identity) => void;
removeIdentity: (id: string) => void;
reset: () => void;
};

Expand Down Expand Up @@ -46,6 +47,19 @@ export const useIdentityStore = create<IdentityStore>()(
return i;
}),
})),
// Removing the active identity hands the active slot to the most recent
// remaining one, so the home screen never points at a missing identity.
removeIdentity: (id) =>
set((state) => {
const identities = state.identities.filter((i) => i.id !== id);
return {
identities,
activeIdentityID:
state.activeIdentityID === id
? identities[0]?.id ?? null
: state.activeIdentityID,
};
}),
reset: () =>
set(() => ({
identities: [],
Expand Down
27 changes: 27 additions & 0 deletions tests/identity-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,30 @@ test("rehydration tolerates a malformed identities field", async () => {
assert.equal(state.activeIdentityID, seeded[1].id);
assert.equal(typeof state.insertIdentity, "function");
});

test("removeIdentity drops the identity and moves the active slot if needed", () => {
useIdentityStore.setState({
activeIdentityID: seeded[0].id,
identities: [...seeded],
});
const { removeIdentity } = useIdentityStore.getState();

// Removing an inactive identity leaves the active one alone.
removeIdentity(seeded[2].id);
assert.deepEqual(
ids(useIdentityStore.getState().identities),
ids([seeded[0], seeded[1], seeded[3], seeded[4]]),
);
assert.equal(useIdentityStore.getState().activeIdentityID, seeded[0].id);

// Removing the active identity hands the slot to the most recent remaining one.
removeIdentity(seeded[0].id);
assert.equal(useIdentityStore.getState().activeIdentityID, seeded[1].id);

// Removing everything leaves no active identity.
for (const identity of [seeded[1], seeded[3], seeded[4]]) {
removeIdentity(identity.id);
}
assert.deepEqual(useIdentityStore.getState().identities, []);
assert.equal(useIdentityStore.getState().activeIdentityID, null);
});
Loading