Skip to content
Open
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
16 changes: 14 additions & 2 deletions spa/src/Board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 }: {
Expand Down Expand Up @@ -169,6 +170,12 @@ export function Board({ data, session, refresh, busyRef, optimisticMove }: {
<AlertIcon />attach channel
</button>
)}
{lane.owner === session.signer.pubkey && (
<button className="icon-btn sm" title="Delete lane"
onClick={() => setModal({ kind: "delete-lane", lane })}>
<TrashIcon />
</button>
)}
</div>
</div>
{lane.description && <p className="lane-desc">{lane.description}</p>}
Expand Down Expand Up @@ -289,6 +296,11 @@ export function Board({ data, session, refresh, busyRef, optimisticMove }: {
<AttachChannelModal lane={modal.lane} session={session}
close={() => setModal({ kind: "none" })} refresh={refresh} />
)}
{modal.kind === "delete-lane" && (
<DeleteLaneModal lane={modal.lane} cardCount={(data.cards.get(modal.lane.address) ?? []).length}
session={session}
close={() => setModal({ kind: "none" })} refresh={refresh} />
)}
</main>
);
}
Expand Down
8 changes: 8 additions & 0 deletions spa/src/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ export const SearchIcon = () => (
</svg>
);

export const TrashIcon = () => (
<svg {...base}>
<path d="M3 6h18" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M10 11v6" /><path d="M14 11v6" />
</svg>
);

export const UsersIcon = () => (
<svg {...base}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
Expand Down
33 changes: 32 additions & 1 deletion spa/src/Modals.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<void>;
}) {
const [confirmName, setConfirmName] = useState("");
const { busy, error, run } = useAction(close, refresh);
return (
<Overlay close={close}>
<h2>Delete {lane.name}?</h2>
<p className="muted">
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.
</p>
<label>
Type <strong>{lane.name}</strong> to confirm
<input autoFocus value={confirmName} onChange={(e) => setConfirmName(e.target.value)} />
</label>
{error && <div className="error">{error}</div>}
<div className="actions">
<button type="button" className="ghost" onClick={close}>Cancel</button>
<button className="pill warn" disabled={busy || confirmName !== lane.name}
onClick={() => run(() => deleteLane(session.relay, session.signer, lane))}>
{busy ? "Deleting…" : "Delete lane"}
</button>
</div>
</Overlay>
);
}

export function DemoThreadModal({ card, nameOf, close }: {
card: Card; nameOf: (pk: string | null | undefined) => string; close: () => void;
}) {
Expand Down
34 changes: 33 additions & 1 deletion spa/src/lib/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,16 +198,40 @@ export const cardOrder = (a: Card, b: Card) => {

// --- fetching ---

// NIP-09 deletion requests (kind 5, ["a", <address>]) 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<Set<string>> {
const deletions = await relay.query([{ kinds: [K.KIND_DELETION], "#a": addresses, limit: 500 }]);
const tombstoned = new Set<string>();
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<BoardData> {
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<string>();

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,
Expand Down Expand Up @@ -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<void> {
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 }[]> {
Expand Down
1 change: 1 addition & 0 deletions spa/src/lib/kinds.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down