diff --git a/src/components/Icon/nucleus.ts b/src/components/Icon/nucleus.ts index 48a0320..e4ed619 100644 --- a/src/components/Icon/nucleus.ts +++ b/src/components/Icon/nucleus.ts @@ -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"; @@ -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), diff --git a/src/components/Row.tsx b/src/components/Row.tsx index e9c5470..7d01f79 100644 --- a/src/components/Row.tsx +++ b/src/components/Row.tsx @@ -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 ( - + )} + + + {props.title} + + {props.detail && ( + + {props.detail} + + )} + + {props.trailing ?? + (props.onClick && ( + + ))} + + {props.actions} + ); } diff --git a/src/components/SelectID/IDRow.tsx b/src/components/SelectID/IDRow.tsx index f553cbf..1f2db54 100644 --- a/src/components/SelectID/IDRow.tsx +++ b/src/components/SelectID/IDRow.tsx @@ -1,10 +1,12 @@ 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; @@ -12,6 +14,8 @@ export default function IDRow(props: { divider?: boolean; }) { const router = useRouter(); + const { removeIdentity } = useIdentity(); + const name = props.identity.meta.name; const detail = useMemo(() => { const credentials = credentialsForIdentity(props.identity); @@ -19,10 +23,15 @@ export default function IDRow(props: { return credentials.map((credential) => credential.title).join(" ยท "); }, [props.identity]); + const handleRemove = () => { + removeIdentity(props.identity.id); + toast(`Removed ${name}`); + }; + return (
  • } divider={props.divider} @@ -35,6 +44,20 @@ export default function IDRow(props: { /> ) : undefined } + actions={ + // Revealed on hover or keyboard focus; always visible where there is no hover. + + } onClick={() => void router.push(`/id/${props.identity.id}`)} />
  • diff --git a/src/hooks/useIdentity.ts b/src/hooks/useIdentity.ts index 37d6daf..f640702 100644 --- a/src/hooks/useIdentity.ts +++ b/src/hooks/useIdentity.ts @@ -15,6 +15,7 @@ const getStore = (store: IdentityStore) => ({ insertIdentity: store.insertIdentity, replaceIdentity: store.replaceIdentity, reset: store.reset, + removeIdentity: store.removeIdentity, }); const useIdentity = () => { @@ -25,6 +26,7 @@ const useIdentity = () => { insertIdentity, replaceIdentity, reset, + removeIdentity, } = useIdentityStore(getStore); const generateIdentityProofsIfNeeded = useCallback( @@ -190,6 +192,7 @@ const useIdentity = () => { ? null : activeIdentityID, generateNextIdentity, + removeIdentity, identities, activeIdentity: activeIdentity, resetIdentityStore, diff --git a/src/pages/select-id/index.tsx b/src/pages/select-id/index.tsx index 9c9dc30..00dfc6f 100644 --- a/src/pages/select-id/index.tsx +++ b/src/pages/select-id/index.tsx @@ -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 (
    void router.push(`/id/${activeIdentityID}`)} + /> + ) + } trailing={ void; insertIdentity: (identity: Identity) => void; replaceIdentity: (identity: Identity) => void; + removeIdentity: (id: string) => void; reset: () => void; }; @@ -46,6 +47,19 @@ export const useIdentityStore = create()( 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: [], diff --git a/tests/identity-store.test.ts b/tests/identity-store.test.ts index b5e36b0..dfa2ff2 100644 --- a/tests/identity-store.test.ts +++ b/tests/identity-store.test.ts @@ -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); +});