From 2e8e750fcb625e96767136a8fecd521a6a492265 Mon Sep 17 00:00:00 2001 From: abduosmanaj Date: Sun, 26 Jul 2026 15:46:32 +0300 Subject: [PATCH] feat: delete a lane Owners can now delete a lane from its header. Publishes a NIP-09 deletion request (kind 5) for the lane's repo announcement; the board client-side filters any lane with a valid tombstone (deletion must be authored by the same pubkey as the lane owner) so it disappears regardless of whether the relay itself prunes the event. Confirmation modal requires typing the lane name to guard against misclicks. Co-Authored-By: Claude Sonnet 5 --- spa/src/Board.tsx | 16 ++++++++++++++-- spa/src/Icons.tsx | 8 ++++++++ spa/src/Modals.tsx | 33 ++++++++++++++++++++++++++++++++- spa/src/lib/board.ts | 34 +++++++++++++++++++++++++++++++++- spa/src/lib/kinds.ts | 1 + 5 files changed, 88 insertions(+), 4 deletions(-) diff --git a/spa/src/Board.tsx b/spa/src/Board.tsx index dff9ba1..6df4fb0 100644 --- a/spa/src/Board.tsx +++ b/spa/src/Board.tsx @@ -7,12 +7,12 @@ import { import { rankBetween } from "./lib/rank"; import { AlertIcon, BotIcon, CheckIcon, ClipboardIcon, GithubIcon, MessageIcon, PlusIcon, - SearchIcon, XIcon, + SearchIcon, TrashIcon, XIcon, } from "./Icons"; import type { Signer } from "./lib/nostr"; import type { Relay } from "./lib/relay"; import { - CardModal, DemoThreadModal, NewCardModal, NewLaneModal, AttachChannelModal, + CardModal, DemoThreadModal, NewCardModal, NewLaneModal, AttachChannelModal, DeleteLaneModal, } from "./Modals"; export interface Session { @@ -27,6 +27,7 @@ type Modal = | { kind: "new-card"; lane: Lane; spinFrom?: Card } | { kind: "new-lane" } | { kind: "attach-channel"; lane: Lane } + | { kind: "delete-lane"; lane: Lane } | { kind: "demo-thread"; card: Card }; export function Board({ data, session, refresh, busyRef, optimisticMove }: { @@ -169,6 +170,12 @@ export function Board({ data, session, refresh, busyRef, optimisticMove }: { attach channel )} + {lane.owner === session.signer.pubkey && ( + + )} {lane.description &&

{lane.description}

} @@ -289,6 +296,11 @@ export function Board({ data, session, refresh, busyRef, optimisticMove }: { setModal({ kind: "none" })} refresh={refresh} /> )} + {modal.kind === "delete-lane" && ( + setModal({ kind: "none" })} refresh={refresh} /> + )} ); } diff --git a/spa/src/Icons.tsx b/spa/src/Icons.tsx index 6cada68..cf61a2d 100644 --- a/spa/src/Icons.tsx +++ b/spa/src/Icons.tsx @@ -78,6 +78,14 @@ export const SearchIcon = () => ( ); +export const TrashIcon = () => ( + + + + + +); + export const UsersIcon = () => ( diff --git a/spa/src/Modals.tsx b/spa/src/Modals.tsx index 9d9545d..3389e53 100644 --- a/spa/src/Modals.tsx +++ b/spa/src/Modals.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { COLUMNS, COLUMN_LABELS, type Column } from "./lib/kinds"; import { - addBoardLinkToCanvas, assignCard, attachChannel, createCard, createLane, + addBoardLinkToCanvas, assignCard, attachChannel, createCard, createLane, deleteLane, fetchMyChannels, githubSlug, laneBoardUrl, moveCard, type Agent, type Card, type Lane, } from "./lib/board"; @@ -291,6 +291,37 @@ export function NewLaneModal({ session, agents, close, refresh }: { ); } +export function DeleteLaneModal({ lane, cardCount, session, close, refresh }: { + lane: Lane; cardCount: number; session: Session; close: () => void; refresh: () => Promise; +}) { + const [confirmName, setConfirmName] = useState(""); + const { busy, error, run } = useAction(close, refresh); + return ( + +

Delete {lane.name}?

+

+ This publishes a deletion request for the lane{cardCount > 0 + ? ` and hides its ${cardCount} card${cardCount === 1 ? "" : "s"} from the board` + : ""}. It relies on the relay honoring NIP-09 deletes — this app will stop + showing the lane regardless, but the underlying events may still exist on the relay. + This can't be undone from here. +

+ + {error &&
{error}
} +
+ + +
+ + ); +} + export function DemoThreadModal({ card, nameOf, close }: { card: Card; nameOf: (pk: string | null | undefined) => string; close: () => void; }) { diff --git a/spa/src/lib/board.ts b/spa/src/lib/board.ts index 0ce7856..54582c5 100644 --- a/spa/src/lib/board.ts +++ b/spa/src/lib/board.ts @@ -198,16 +198,40 @@ export const cardOrder = (a: Card, b: Card) => { // --- fetching --- +// NIP-09 deletion requests (kind 5, ["a",
]) for repo announcements. +// Only a deletion authored by the address's own pubkey may tombstone it — +// otherwise anyone could hide someone else's lane by forging a kind 5. +async function fetchTombstonedAddresses(relay: Relay, addresses: string[]): Promise> { + const deletions = await relay.query([{ kinds: [K.KIND_DELETION], "#a": addresses, limit: 500 }]); + const tombstoned = new Set(); + for (const ev of deletions) { + for (const addr of tagValues(ev, "a")) { + if (addr.startsWith(`30617:${ev.pubkey.toLowerCase()}:`)) tombstoned.add(addr); + } + } + return tombstoned; +} + export async function fetchBoard(relay: Relay, myPubkey: string): Promise { const repoEvents = latestPerCoord( await relay.query([{ kinds: [K.KIND_REPO_ANNOUNCEMENT], limit: 200 }]), ); + const candidateAddresses = repoEvents.flatMap((ev) => { + const d = tagValue(ev, "d"); + return d ? [`30617:${ev.pubkey.toLowerCase()}:${d}`] : []; + }); + const tombstoned = candidateAddresses.length + ? await fetchTombstonedAddresses(relay, candidateAddresses) + : new Set(); + const lanes: Lane[] = repoEvents .flatMap((ev) => { const d = tagValue(ev, "d"); if (!d) return []; + const address = `30617:${ev.pubkey.toLowerCase()}:${d}`; + if (tombstoned.has(address)) return []; return [{ - address: `30617:${ev.pubkey.toLowerCase()}:${d}`, + address, owner: ev.pubkey.toLowerCase(), repoId: d, name: tagValue(ev, "name") ?? d, @@ -556,6 +580,14 @@ export async function attachChannel( await relay.submit(signer.signEvent(K.KIND_REPO_ANNOUNCEMENT, tags, "")); } +export async function deleteLane(relay: Relay, signer: Signer, lane: Lane): Promise { + await relay.submit(signer.signEvent( + K.KIND_DELETION, + [["a", lane.address], ["k", String(K.KIND_REPO_ANNOUNCEMENT)]], + "", + )); +} + export async function fetchMyChannels( relay: Relay, myPubkey: string, ): Promise<{ uuid: string; name: string }[]> { diff --git a/spa/src/lib/kinds.ts b/spa/src/lib/kinds.ts index b303184..fc1a4c0 100644 --- a/spa/src/lib/kinds.ts +++ b/spa/src/lib/kinds.ts @@ -1,4 +1,5 @@ export const KIND_PROFILE = 0; +export const KIND_DELETION = 5; export const KIND_STREAM_MESSAGE = 9; export const KIND_GIT_ISSUE = 1621; export const KIND_STATUS_OPEN = 1630;