fix(projects): allow owners to delete agent projects (#6533)

## Summary

- expose project deletion when the current user directly owns the
project or owns/manages its author agent
- publish a viewer-signed, project-only tombstone after fetching the
exact live head, then detect concurrent replacement
- honor relay-authorized owner-signed tombstones in desktop project read
models

## Testing

- pre-push hook: desktop Biome, TypeScript, 5,358 desktop tests, Rust
tests, Tauri checks, and file-size ratchet passed at `a36f7a4cf`
- `cargo check -p buzz-test-client --tests`
- relay E2E coverage added as ignored; not run against a live relay

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Signed-off-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-08-27 09:33:04 -06:00
committed by GitHub
parent 27c1a7c91b
commit 9176e34405
12 changed files with 540 additions and 58 deletions

View File

@@ -25,6 +25,7 @@
use std::time::Duration;
use buzz_sdk::nip_oa;
use buzz_test_client::BuzzTestClient;
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};
@@ -92,8 +93,14 @@ fn repo_announcement(keys: &Keys, repo_d: &str) -> nostr::Event {
/// A NIP-09 `a`-tag-only deletion at a NIP-33 coordinate. No `e` tag, so the
/// relay takes the coordinate-delete path rather than the event-id path.
/// `created_at` defaults to now when `None`.
fn coordinate_delete(keys: &Keys, kind: u16, d_tag: &str, created_at: Option<u64>) -> nostr::Event {
let coord = format!("{kind}:{}:{d_tag}", keys.public_key().to_hex());
fn coordinate_delete_for_author(
signer: &Keys,
author: &Keys,
kind: u16,
d_tag: &str,
created_at: Option<u64>,
) -> nostr::Event {
let coord = format!("{kind}:{}:{d_tag}", author.public_key().to_hex());
let builder =
EventBuilder::new(Kind::Custom(5), "")
.tags(vec![Tag::parse(["a", coord.as_str()]).unwrap()]);
@@ -101,10 +108,28 @@ fn coordinate_delete(keys: &Keys, kind: u16, d_tag: &str, created_at: Option<u64
Some(ts) => builder.custom_created_at(Timestamp::from(ts)),
None => builder,
}
.sign_with_keys(keys)
.sign_with_keys(signer)
.unwrap()
}
fn coordinate_delete(keys: &Keys, kind: u16, d_tag: &str, created_at: Option<u64>) -> nostr::Event {
coordinate_delete_for_author(keys, keys, kind, d_tag, created_at)
}
async fn connect_agent_with_owner(agent: &Keys, owner: &Keys) -> BuzzTestClient {
let tag_json = nip_oa::compute_auth_tag(owner, &agent.public_key(), "kind=9")
.expect("compute NIP-OA auth tag");
let auth_tag = nip_oa::parse_auth_tag(&tag_json).expect("parse NIP-OA auth tag");
let mut client = BuzzTestClient::connect_unauthenticated(&relay_url())
.await
.expect("connect agent unauthenticated");
client
.authenticate_with_nip_oa(agent, &auth_tag)
.await
.expect("authenticate agent with NIP-OA owner");
client
}
fn addressable_filter(kind: u16, author: &Keys, d_tag: &str) -> Filter {
Filter::new()
.kind(Kind::Custom(kind))
@@ -345,6 +370,93 @@ async fn test_project_tombstone_deletes_coordinate_and_spares_members() {
client.disconnect().await.expect("disconnect");
}
/// NIP-OA extends NIP-09 coordinate ownership: a human owner may delete an
/// agent-authored project, while an unrelated signer must be rejected without
/// changing the live project head.
#[tokio::test]
#[ignore]
async fn test_agent_owner_can_delete_agent_project_but_third_party_cannot() {
let agent = Keys::generate();
let owner = Keys::generate();
let third_party = Keys::generate();
let project_d = unique("agent-owned-project");
let mut agent_client = connect_agent_with_owner(&agent, &owner).await;
let ok = agent_client
.send_event(project_event(
&agent,
&project_d,
"Agent project",
&[],
None,
))
.await
.expect("send agent project");
assert!(ok.accepted, "relay rejected agent project: {}", ok.message);
let mut third_party_client = BuzzTestClient::connect(&relay_url(), &third_party)
.await
.expect("connect third party");
let ok = third_party_client
.send_event(coordinate_delete_for_author(
&third_party,
&agent,
PROJECT_KIND,
&project_d,
None,
))
.await
.expect("send third-party tombstone");
assert!(
!ok.accepted,
"unrelated signer deleted an agent-owned project"
);
let still_live = query(
&mut third_party_client,
"agent-owner-third-party-rejected",
addressable_filter(PROJECT_KIND, &agent, &project_d),
)
.await;
assert_eq!(
still_live.len(),
1,
"rejected tombstone changed project state"
);
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner)
.await
.expect("connect owner");
let ok = owner_client
.send_event(coordinate_delete_for_author(
&owner,
&agent,
PROJECT_KIND,
&project_d,
None,
))
.await
.expect("send owner tombstone");
assert!(
ok.accepted,
"relay rejected owner deletion of agent project: {}",
ok.message
);
let deleted = query(
&mut owner_client,
"agent-owner-deleted",
addressable_filter(PROJECT_KIND, &agent, &project_d),
)
.await;
assert!(deleted.is_empty(), "owner tombstone left project live");
agent_client.disconnect().await.expect("disconnect agent");
third_party_client
.disconnect()
.await
.expect("disconnect third party");
owner_client.disconnect().await.expect("disconnect owner");
}
/// NIP-09 scopes an `a`-tag deletion to versions at or before the deletion's own
/// `created_at`. A tombstone signed between V1 and V2 — delayed in transit or
/// replayed by a third party — must therefore retire V1 only and leave the newer

View File

@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import test from "node:test";
import { deleteProject } from "./projectDeletion.ts";
const OWNER = "a".repeat(64);
const VIEWER = "b".repeat(64);
const PROJECT_ADDRESS = `30621:${OWNER}:platform`;
const project = {
createdAt: 50,
description: "",
dtag: "platform",
id: PROJECT_ADDRESS,
legacy: false,
name: "Platform",
owner: OWNER,
primaryRepositoryAddress: `30617:${OWNER}:repo`,
projectAddress: PROJECT_ADDRESS,
projectChannelId: null,
repositories: [],
repositoryAddresses: [`30617:${OWNER}:repo`],
status: "active",
};
function event(overrides = {}) {
return {
id: "1".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 75,
content: "",
tags: [["d", "platform"]],
...overrides,
};
}
test("deleteProject lets the relay authorize an agent owner and tombstones only the project", async () => {
const calls = [];
await deleteProject(project, {
fetchEvents: async (filter) => {
calls.push(["fetch", filter]);
return calls.filter(([type]) => type === "fetch").length === 1
? [event()]
: [];
},
nowSeconds: () => 74,
signEvent: async (template) => {
calls.push(["sign", template]);
return event({
pubkey: VIEWER,
kind: template.kind,
content: template.content,
created_at: template.createdAt,
tags: template.tags,
});
},
publishEvent: async (signed) => {
calls.push(["publish", signed]);
},
});
assert.deepEqual(calls[0][1], {
kinds: [30621],
authors: [OWNER],
"#d": ["platform"],
limit: 1,
});
assert.deepEqual(calls[1][1].tags, [["a", PROJECT_ADDRESS]]);
assert.equal(calls[1][1].createdAt, 76);
assert.equal(calls[2][1].pubkey, VIEWER);
assert.deepEqual(calls[3][1], calls[0][1]);
assert.equal(calls[1][1].content, "Delete project Platform");
});
test("deleteProject builds a one-coordinate tombstone", async () => {
const calls = [];
await deleteProject(project, {
fetchEvents: async () => (calls.length === 0 ? [event()] : []),
signEvent: async (template) => {
calls.push(template);
return event({
kind: template.kind,
content: template.content,
created_at: template.createdAt,
tags: template.tags,
});
},
publishEvent: async () => {},
});
assert.equal(calls[0].kind, 5);
assert.deepEqual(calls[0].tags, [["a", PROJECT_ADDRESS]]);
});
test("deleteProject fails closed when the live project head is missing", async () => {
await assert.rejects(
deleteProject(project, { fetchEvents: async () => [] }),
/Could not find this project on the relay/,
);
});
test("deleteProject reports a concurrent replacement that survives", async () => {
let fetchCount = 0;
await assert.rejects(
deleteProject(project, {
fetchEvents: async () => {
fetchCount += 1;
return [event({ created_at: fetchCount === 1 ? 75 : 77 })];
},
nowSeconds: () => 74,
signEvent: async (template) =>
event({
pubkey: VIEWER,
kind: template.kind,
created_at: template.createdAt,
tags: template.tags,
}),
publishEvent: async () => {},
}),
/updated while it was being deleted/,
);
});
test("deleteProject reports uncertain outcome when publish acknowledgement is lost", async () => {
await assert.rejects(
deleteProject(project, {
fetchEvents: async () => [event()],
signEvent: async (template) =>
event({
kind: template.kind,
created_at: template.createdAt,
tags: template.tags,
}),
publishEvent: async (_event, timeoutMessage) => {
throw new Error(timeoutMessage);
},
}),
/Could not confirm whether the project was deleted\. Projects were refreshed\./,
);
});

View File

@@ -14,7 +14,6 @@ import {
listProjectLocalRepositories,
} from "@/shared/api/projectGit";
import {
KIND_DELETION,
KIND_GIT_ISSUE,
KIND_GIT_PATCH,
KIND_GIT_PR_UPDATE,
@@ -59,6 +58,10 @@ import {
projectPullRequestEventsToPullRequests,
} from "./projectPullRequests.mjs";
import { fetchProjectsWorkItems } from "./projectWorkItems";
import {
projectDeletionMutationOptions,
projectsQueryKey,
} from "./projectDeletionMutation";
import {
eventToRepository,
type Project,
@@ -71,6 +74,8 @@ import {
} from "./projectEnumeration";
import { projectMatchesRouteId } from "./projectRoutes";
export { projectsQueryKey };
export type {
Project,
ProjectIssue,
@@ -624,27 +629,6 @@ async function fetchProjectActivitySummaries(
);
}
async function deleteProject(project: Project): Promise<void> {
const identity = await getIdentity();
if (identity.pubkey.toLowerCase() !== project.owner.toLowerCase()) {
throw new Error("Only the project owner can delete this project.");
}
const event = await signRelayEvent({
kind: KIND_DELETION,
content: `Delete project ${project.name}`,
tags: [["a", project.projectAddress]],
});
await relayClient.publishEvent(
event,
"Timed out deleting project.",
"Failed to delete project.",
);
}
export const projectsQueryKey = ["projects"] as const;
/**
* Freshness windows for the Projects surface. Every local write path
* invalidates its keys explicitly (issue/PR mutations, project creation,
@@ -987,13 +971,5 @@ export function useProjectActivitySummariesQuery(projects: Project[]) {
export function useDeleteProjectMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteProject,
onSuccess: (_data, project) => {
queryClient.setQueryData<Project[]>(projectsQueryKey, (current = []) =>
current.filter((item) => item.id !== project.id),
);
void queryClient.invalidateQueries({ queryKey: projectsQueryKey });
},
});
return useMutation(projectDeletionMutationOptions(queryClient));
}

View File

@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildProjectDeletionTemplate,
canDeleteProject,
} from "./projectDeletion.ts";
const AGENT = "a".repeat(64);
const OWNER = "b".repeat(64);
const OTHER = "c".repeat(64);
const PROJECT_ADDRESS = `30621:${AGENT}:platform`;
const project = {
name: "Platform",
owner: AGENT,
projectAddress: PROJECT_ADDRESS,
};
test("project deletion capability includes direct and agent owners", () => {
assert.equal(canDeleteProject(project, AGENT, undefined), true);
assert.equal(
canDeleteProject(project, OWNER, { [AGENT]: { ownerPubkey: OWNER } }),
true,
);
});
test("project deletion capability rejects unrelated viewers", () => {
assert.equal(canDeleteProject(project, OWNER, undefined), false);
assert.equal(
canDeleteProject(project, OTHER, { [AGENT]: { ownerPubkey: OWNER } }),
false,
);
});
test("project deletion tombstone targets only the container and dominates its head", () => {
assert.deepEqual(
buildProjectDeletionTemplate(project, { created_at: 101 }, 100),
{
kind: 5,
content: "Delete project Platform",
createdAt: 102,
tags: [["a", PROJECT_ADDRESS]],
},
);
});

View File

@@ -0,0 +1,112 @@
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { ownsAuthorAgent } from "@/features/profile/lib/identity";
import { relayClient } from "@/shared/api/relayClient";
import type { RelayEvent } from "@/shared/api/types";
import { signRelayEvent } from "@/shared/api/tauri";
import {
KIND_DELETION,
KIND_PROJECT_ANNOUNCEMENT,
KIND_REPO_ANNOUNCEMENT,
} from "@/shared/constants/kinds";
import { normalizePubkey } from "@/shared/lib/pubkey";
import type { Project } from "./projectModels";
export type DeleteProjectEventTemplate = {
kind: number;
content: string;
createdAt: number;
tags: string[][];
};
/**
* Buzz lets a human owner manage content authored by their NIP-OA agent, just
* as it does for agent-owned channels. The profile ownership evidence mirrors
* the relay authority used for the owner-signed tombstone.
*/
export function canDeleteProject(
project: Pick<Project, "owner">,
currentPubkey: string | undefined,
profiles: UserProfileLookup | undefined,
): boolean {
if (!currentPubkey) return false;
const owner = normalizePubkey(project.owner);
return (
owner === normalizePubkey(currentPubkey) ||
ownsAuthorAgent(profiles?.[owner], currentPubkey)
);
}
/** Build a tombstone that dominates the exact live coordinate head. */
export function buildProjectDeletionTemplate(
project: Pick<Project, "name" | "projectAddress">,
liveHead: Pick<RelayEvent, "created_at">,
nowSeconds = Math.floor(Date.now() / 1_000),
): DeleteProjectEventTemplate {
return {
kind: KIND_DELETION,
content: `Delete project ${project.name}`,
createdAt: Math.max(nowSeconds, liveHead.created_at + 1),
tags: [["a", project.projectAddress]],
};
}
type ProjectDeletionFetchEvents = (filter: {
kinds: number[];
authors: string[];
"#d": string[];
limit: number;
}) => Promise<RelayEvent[]>;
type ProjectDeletionDeps = {
fetchEvents: ProjectDeletionFetchEvents;
nowSeconds: () => number;
publishEvent: (
event: RelayEvent,
timeoutMessage: string,
failureMessage: string,
) => Promise<void>;
signEvent: (input: DeleteProjectEventTemplate) => Promise<RelayEvent>;
};
/** Delete the exact live project coordinate and detect a concurrent replacement. */
export async function deleteProject(
project: Project,
deps?: Partial<ProjectDeletionDeps>,
): Promise<void> {
const {
fetchEvents = relayClient.fetchEvents.bind(relayClient),
nowSeconds = () => Math.floor(Date.now() / 1_000),
publishEvent = relayClient.publishEvent.bind(relayClient),
signEvent = signRelayEvent,
} = deps ?? {};
const filter = {
kinds: [
project.legacy ? KIND_REPO_ANNOUNCEMENT : KIND_PROJECT_ANNOUNCEMENT,
],
authors: [project.owner.toLowerCase()],
"#d": [project.dtag],
limit: 1,
};
const [liveHead] = await fetchEvents(filter);
if (!liveHead) {
throw new Error(
"Could not find this project on the relay. Refresh and try again.",
);
}
const event = await signEvent(
buildProjectDeletionTemplate(project, liveHead, nowSeconds()),
);
await publishEvent(
event,
"Could not confirm whether the project was deleted. Projects were refreshed.",
"Failed to delete project.",
);
if ((await fetchEvents(filter)).length > 0) {
throw new Error(
"This project was updated while it was being deleted. Refresh and try again.",
);
}
}

View File

@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
MutationObserver,
QueryClient,
QueryObserver,
} from "@tanstack/react-query";
import {
projectDeletionMutationOptions,
projectsQueryKey,
} from "./projectDeletionMutation.ts";
const project = {
id: "30621:owner:platform",
name: "Platform",
};
test("lost deletion acknowledgement refetches and removes the stale project", async () => {
let fetchCount = 0;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const queryObserver = new QueryObserver(queryClient, {
queryKey: projectsQueryKey,
queryFn: async () => {
fetchCount += 1;
return fetchCount === 1 ? [project] : [];
},
});
const unsubscribeQuery = queryObserver.subscribe(() => {});
await queryObserver.refetch();
assert.deepEqual(queryClient.getQueryData(projectsQueryKey), [project]);
const mutationObserver = new MutationObserver(
queryClient,
projectDeletionMutationOptions(queryClient, async () => {
throw new Error(
"Could not confirm whether the project was deleted. Projects were refreshed.",
);
}),
);
await assert.rejects(mutationObserver.mutate(project), /Could not confirm/);
assert.equal(fetchCount, 2);
assert.deepEqual(queryClient.getQueryData(projectsQueryKey), []);
unsubscribeQuery();
});
test("successful deletion removes the project before refetch", async () => {
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
queryClient.setQueryData(projectsQueryKey, [project]);
const mutationObserver = new MutationObserver(
queryClient,
projectDeletionMutationOptions(queryClient, async () => {}),
);
await mutationObserver.mutate(project);
assert.deepEqual(queryClient.getQueryData(projectsQueryKey), []);
});

View File

@@ -0,0 +1,24 @@
import type { QueryClient } from "@tanstack/react-query";
import type { Project } from "./projectModels";
import { deleteProject } from "./projectDeletion";
export const projectsQueryKey = ["projects"] as const;
/** Keep the shared project cache authoritative even when publish ACK is lost. */
export function projectDeletionMutationOptions(
queryClient: QueryClient,
deleteProjectFn: (project: Project) => Promise<void> = deleteProject,
) {
type DeletionResult = Awaited<ReturnType<typeof deleteProjectFn>>;
return {
mutationFn: deleteProjectFn,
onSuccess: (_data: DeletionResult, project: Project) => {
queryClient.setQueryData<Project[]>(projectsQueryKey, (current = []) =>
current.filter((item) => item.id !== project.id),
);
},
onSettled: () =>
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
};
}

View File

@@ -546,9 +546,9 @@ test("buildProjectReadModels ignores a tombstone that predates the live head", (
);
});
test("buildProjectReadModels rejects a tombstone signed by a different pubkey", () => {
test("buildProjectReadModels applies a relay-authorized owner tombstone", () => {
const owner = "a".repeat(64);
const impostor = "b".repeat(64);
const agentOwner = "b".repeat(64);
const projectAddress = `30621:${owner}:platform`;
const projectEvent = {
id: "p".repeat(64),
@@ -558,10 +558,10 @@ test("buildProjectReadModels rejects a tombstone signed by a different pubkey",
content: "",
tags: [["d", "platform"]],
};
const foreignDeletion = {
const ownerDeletion = {
id: "d".repeat(64),
kind: 5,
pubkey: impostor,
pubkey: agentOwner,
created_at: 200,
content: "",
tags: [["a", projectAddress]],
@@ -570,12 +570,12 @@ test("buildProjectReadModels rejects a tombstone signed by a different pubkey",
const projects = buildProjectReadModels({
projectEvents: [projectEvent],
repositoryEvents: [],
deletionEvents: [foreignDeletion],
deletionEvents: [ownerDeletion],
});
assert.equal(
projects.filter((p) => !p.legacy).length,
1,
"a stranger's tombstone must not delete someone else's project",
0,
"relay-authorized owner tombstone should delete the agent project",
);
});

View File

@@ -421,27 +421,18 @@ function repositoryToLegacyProject(repository: Repository): Project {
}
/**
* Builds the set of addressable coordinates that have been authoritatively
* deleted per NIP-09 semantics: the deletion signer must equal the coordinate
* owner, and the deletion's `created_at` must be ≥ the live head's timestamp.
* Returns a `Map<coordinate, deletedAt>` for threshold comparison.
* Builds deletion thresholds from relay-accepted tombstones. The relay has
* already enforced that each signer controls the addressed coordinate,
* including Buzz's NIP-OA owner delegation for agent-authored events.
*/
function buildDeletionThresholds(
deletionEvents: RelayEvent[],
): Map<string, number> {
const thresholds = new Map<string, number>();
for (const event of deletionEvents) {
const signer = event.pubkey.toLowerCase();
for (const tag of event.tags) {
if (tag[0] !== "a" || !tag[1]) continue;
const coordinate = tag[1];
// The signer must be the owner of the coordinate.
const firstColon = coordinate.indexOf(":");
const secondColon = coordinate.indexOf(":", firstColon + 1);
if (firstColon < 0 || secondColon < 0) continue;
const owner = coordinate.slice(firstColon + 1, secondColon).toLowerCase();
if (owner !== signer) continue;
// Keep the latest (most permissive) deletion threshold.
const existing = thresholds.get(coordinate);
if (existing === undefined || event.created_at > existing) {
thresholds.set(coordinate, event.created_at);

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { canDeleteProject } from "@/features/projects/projectDeletion";
import { FolderGit2, Folders } from "lucide-react";
import type {
Project,
@@ -17,7 +18,6 @@ import {
repositoryShareLink,
} from "@/features/projects/lib/projectShareLinks";
import {
isProjectOwnedByCurrentUser,
isProjectMine,
projectPeople,
type ProjectsViewMode,
@@ -228,9 +228,10 @@ export function ProjectsOverviewProjectItems({
key={project.id}
>
<ProjectGridCard
canDelete={isProjectOwnedByCurrentUser(
canDelete={canDeleteProject(
project,
currentPubkey,
profiles,
)}
deleteDisabled={deleteDisabled}
hasLocal={hasLocalCheckout(project, localRepoNames)}
@@ -272,9 +273,10 @@ export function ProjectsOverviewProjectItems({
key={project.id}
>
<ProjectListRow
canDelete={isProjectOwnedByCurrentUser(
canDelete={canDeleteProject(
project,
currentPubkey,
profiles,
)}
deleteDisabled={deleteDisabled}
hasLocal={hasLocalCheckout(project, localRepoNames)}

View File

@@ -0,0 +1,14 @@
import * as React from "react";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import type { Project } from "./projectModels";
/** Load relay-backed ownership profiles for project deletion capability. */
export function useProjectOwnerProfiles(projects: Project[]) {
const ownerPubkeys = React.useMemo(
() => [...new Set(projects.map((project) => project.owner))],
[projects],
);
return useUsersBatchQuery(ownerPubkeys, { enabled: ownerPubkeys.length > 0 })
.data?.profiles;
}

View File

@@ -24,7 +24,8 @@ import {
useProjectsQuery,
} from "@/features/projects/hooks";
import { listProjectChildChannels } from "@/features/projects/lib/projectRelatedChannels";
import { isProjectOwnedByCurrentUser } from "@/features/projects/lib/projectsViewHelpers";
import { canDeleteProject } from "@/features/projects/projectDeletion";
import { useProjectOwnerProfiles } from "@/features/projects/useProjectOwnerProfiles";
import { projectShareLink } from "@/features/projects/lib/projectShareLinks";
import {
addProjectToSidebar,
@@ -128,6 +129,7 @@ function SidebarProjectsSectionContent() {
const projectsQuery = useProjectsQuery();
const channelsQuery = useChannelsQuery();
const identityQuery = useIdentityQuery();
const ownerProfiles = useProjectOwnerProfiles(projectsQuery.data ?? []);
const currentPubkey = identityQuery.data?.pubkey;
const { goChannel, goProject, goProjects } = useAppNavigation();
const pathname = useLocation({ select: (location) => location.pathname });
@@ -311,9 +313,10 @@ function SidebarProjectsSectionContent() {
return (
<React.Fragment key={project.id}>
<SidebarProjectRow
canDelete={isProjectOwnedByCurrentUser(
canDelete={canDeleteProject(
project,
currentPubkey,
ownerProfiles,
)}
childCount={childChannels.length}
deleteDisabled={deleteProjectMutation.isPending}