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;