mirror of
https://github.com/block/buzz.git
synced 2026-11-08 02:11:36 +00:00
feat(projects): make project creation channel-first
Create an explicit project with a home channel and default repository while preserving NIP-MP folding, deletion, and retry semantics. Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
299
desktop/src/features/projects/createProject.ts
Normal file
299
desktop/src/features/projects/createProject.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
createChannelManagedAgents,
|
||||
type CreateChannelManagedAgentInput,
|
||||
} from "@/features/agents/channelAgents";
|
||||
import { fetchProjects, type Project } from "@/features/projects/hooks";
|
||||
import {
|
||||
buildDefaultProjectRepositoryTemplate,
|
||||
buildProjectBootstrapTemplates,
|
||||
conflictingListedProject,
|
||||
isUnsupportedProjectKindError,
|
||||
projectDtagFromName,
|
||||
type ProjectListingVisibility,
|
||||
} from "@/features/projects/projectCreation";
|
||||
import { buildProjectPatchTemplate } from "@/features/projects/projectRepositoryCreation";
|
||||
import { buildProjectReadModels } from "@/features/projects/projectModels";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { createChannel, signRelayEvent } from "@/shared/api/tauri";
|
||||
import { getIdentity } from "@/shared/api/tauriIdentity";
|
||||
import type {
|
||||
Channel,
|
||||
ChannelVisibility,
|
||||
RelayEvent,
|
||||
} from "@/shared/api/types";
|
||||
import {
|
||||
KIND_PROJECT_ANNOUNCEMENT,
|
||||
KIND_REPO_ANNOUNCEMENT,
|
||||
} from "@/shared/constants/kinds";
|
||||
import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl";
|
||||
|
||||
export type CreateProjectInput = {
|
||||
name: string;
|
||||
description?: string;
|
||||
channelVisibility?: ChannelVisibility;
|
||||
projectVisibility?: ProjectListingVisibility;
|
||||
agents?: readonly CreateChannelManagedAgentInput[];
|
||||
};
|
||||
|
||||
export type CreateProjectResult = {
|
||||
channel: Channel | null;
|
||||
/** Retained for callers shared with legacy repository-only creation. */
|
||||
compatibilityWarning?: string;
|
||||
project: Project;
|
||||
};
|
||||
|
||||
export type CreateProjectResumeState = {
|
||||
channels: Map<string, Channel>;
|
||||
projectIds: Set<string>;
|
||||
};
|
||||
|
||||
function formatAgentFailures(
|
||||
failures: ReadonlyArray<{ name: string; error: string }>,
|
||||
) {
|
||||
if (failures.length === 1) {
|
||||
const [failure] = failures;
|
||||
return `The project was created, but adding ${failure.name} failed: ${failure.error}`;
|
||||
}
|
||||
return `The project was created, but adding agents failed: ${failures
|
||||
.map((failure) => `${failure.name}: ${failure.error}`)
|
||||
.join("; ")}`;
|
||||
}
|
||||
|
||||
async function publishProjectEvent(event: RelayEvent) {
|
||||
try {
|
||||
await relayClient.publishEvent(
|
||||
event,
|
||||
"Timed out creating project.",
|
||||
"Failed to create project.",
|
||||
);
|
||||
} catch (error) {
|
||||
if (isUnsupportedProjectKindError(error)) {
|
||||
throw new Error(
|
||||
"This relay does not support projects yet, so a project channel cannot be published here.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function publishRepositoryEvent(event: RelayEvent) {
|
||||
await relayClient.publishEvent(
|
||||
event,
|
||||
"Timed out creating the project repository.",
|
||||
"Failed to create the project repository.",
|
||||
);
|
||||
}
|
||||
|
||||
function readCreatedProject(
|
||||
projectEvent: RelayEvent,
|
||||
repositoryEvent: RelayEvent | null,
|
||||
): Project {
|
||||
const [project] = buildProjectReadModels({
|
||||
projectEvents: [projectEvent],
|
||||
repositoryEvents: repositoryEvent ? [repositoryEvent] : [],
|
||||
relayOrigin: getCachedRelayOrigin(),
|
||||
});
|
||||
if (!project) {
|
||||
throw new Error("The project was created but could not be read.");
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
async function addRequestedAgents(
|
||||
channelId: string,
|
||||
agents: readonly CreateChannelManagedAgentInput[] | undefined,
|
||||
) {
|
||||
if (!agents || agents.length === 0) return;
|
||||
const result = await createChannelManagedAgents(channelId, agents);
|
||||
if (result.failures.length > 0) {
|
||||
throw new Error(formatAgentFailures(result.failures));
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOwnHead(
|
||||
kind: number,
|
||||
ownerPubkey: string,
|
||||
dtag: string,
|
||||
): Promise<RelayEvent | null> {
|
||||
const events = await relayClient.fetchEvents({
|
||||
kinds: [kind],
|
||||
authors: [ownerPubkey],
|
||||
"#d": [dtag],
|
||||
limit: 1,
|
||||
});
|
||||
return events[0] ?? null;
|
||||
}
|
||||
|
||||
async function ensureDefaultRepository({
|
||||
channelId,
|
||||
input,
|
||||
ownerPubkey,
|
||||
project,
|
||||
}: {
|
||||
channelId: string;
|
||||
input: CreateProjectInput;
|
||||
ownerPubkey: string;
|
||||
project: Project;
|
||||
}): Promise<Project> {
|
||||
const repositoryTemplate = buildDefaultProjectRepositoryTemplate({
|
||||
description: input.description,
|
||||
name: input.name,
|
||||
ownerPubkey,
|
||||
projectChannelId: channelId,
|
||||
});
|
||||
const existingRepository =
|
||||
project.repositories.find(
|
||||
(repository) =>
|
||||
repository.repoAddress === repositoryTemplate.repositoryAddress,
|
||||
) ?? null;
|
||||
if (existingRepository) return project;
|
||||
|
||||
let repositoryEvent = await fetchOwnHead(
|
||||
KIND_REPO_ANNOUNCEMENT,
|
||||
ownerPubkey,
|
||||
repositoryTemplate.dtag,
|
||||
);
|
||||
if (!repositoryEvent) {
|
||||
repositoryEvent = await signRelayEvent(repositoryTemplate.repository);
|
||||
await publishRepositoryEvent(repositoryEvent);
|
||||
}
|
||||
|
||||
if (
|
||||
project.repositoryAddresses.includes(repositoryTemplate.repositoryAddress)
|
||||
) {
|
||||
const liveProject =
|
||||
(await fetchOwnHead(
|
||||
KIND_PROJECT_ANNOUNCEMENT,
|
||||
ownerPubkey,
|
||||
project.dtag,
|
||||
)) ?? null;
|
||||
if (!liveProject) {
|
||||
throw new Error("The project was created but could not be read.");
|
||||
}
|
||||
return readCreatedProject(liveProject, repositoryEvent);
|
||||
}
|
||||
|
||||
const liveHead = await fetchOwnHead(
|
||||
KIND_PROJECT_ANNOUNCEMENT,
|
||||
ownerPubkey,
|
||||
project.dtag,
|
||||
);
|
||||
if (!liveHead) {
|
||||
throw new Error(
|
||||
"Could not find this project on the relay. Refresh and try again.",
|
||||
);
|
||||
}
|
||||
const patched = buildProjectPatchTemplate({
|
||||
liveHead,
|
||||
ownerPubkey,
|
||||
repositoryAddresses: [
|
||||
...new Set([
|
||||
...project.repositoryAddresses,
|
||||
repositoryTemplate.repositoryAddress,
|
||||
]),
|
||||
].sort(),
|
||||
});
|
||||
const projectEvent = await signRelayEvent(patched);
|
||||
await publishProjectEvent(projectEvent);
|
||||
return readCreatedProject(projectEvent, repositoryEvent);
|
||||
}
|
||||
|
||||
async function finishCreate(
|
||||
channel: Channel | null,
|
||||
project: Project,
|
||||
input: CreateProjectInput,
|
||||
resume: CreateProjectResumeState,
|
||||
projectId: string,
|
||||
): Promise<CreateProjectResult> {
|
||||
const agentChannelId = channel?.id ?? project.projectChannelId;
|
||||
if (agentChannelId && input.agents && input.agents.length > 0) {
|
||||
await addRequestedAgents(agentChannelId, input.agents);
|
||||
}
|
||||
resume.projectIds.delete(projectId);
|
||||
resume.channels.delete(projectId);
|
||||
return { channel, project };
|
||||
}
|
||||
|
||||
/** Creates the home channel, a bound default repository, and the NIP-MP project. */
|
||||
export async function createProject(
|
||||
input: CreateProjectInput,
|
||||
resume: CreateProjectResumeState,
|
||||
): Promise<CreateProjectResult> {
|
||||
const identity = await getIdentity();
|
||||
const dtagPreview = projectDtagFromName(input.name);
|
||||
if (!dtagPreview) {
|
||||
throw new Error("Project name must include letters or numbers.");
|
||||
}
|
||||
const existing = await fetchProjects();
|
||||
const ownerPubkey = identity.pubkey.toLowerCase();
|
||||
const existingProject = existing.find(
|
||||
(project) =>
|
||||
project.owner.toLowerCase() === ownerPubkey &&
|
||||
project.dtag === dtagPreview,
|
||||
);
|
||||
const projectId = `${ownerPubkey}:${dtagPreview}`;
|
||||
const canResume = resume.projectIds.has(projectId);
|
||||
if (existingProject && !canResume) {
|
||||
throw new Error(`You already have a project named "${dtagPreview}".`);
|
||||
}
|
||||
if (existingProject && !existingProject.legacy) {
|
||||
const cachedChannel = resume.channels.get(projectId) ?? null;
|
||||
const channelId =
|
||||
cachedChannel?.id ?? existingProject.projectChannelId ?? "";
|
||||
const project = channelId
|
||||
? await ensureDefaultRepository({
|
||||
channelId,
|
||||
input,
|
||||
ownerPubkey,
|
||||
project: existingProject,
|
||||
})
|
||||
: existingProject;
|
||||
return finishCreate(cachedChannel, project, input, resume, projectId);
|
||||
}
|
||||
const conflict = conflictingListedProject(existing, {
|
||||
dtag: dtagPreview,
|
||||
name: input.name,
|
||||
ownerPubkey,
|
||||
});
|
||||
if (conflict) {
|
||||
throw new Error(
|
||||
`A project named "${conflict.name}" already exists. Open that one instead of creating another.`,
|
||||
);
|
||||
}
|
||||
|
||||
resume.projectIds.add(projectId);
|
||||
let channel = resume.channels.get(projectId);
|
||||
if (!channel) {
|
||||
channel = await createChannel({
|
||||
channelType: "stream",
|
||||
description: input.description,
|
||||
name: input.name.trim(),
|
||||
visibility: input.channelVisibility ?? "open",
|
||||
});
|
||||
resume.channels.set(projectId, channel);
|
||||
}
|
||||
|
||||
const templates = buildProjectBootstrapTemplates({
|
||||
description: input.description,
|
||||
name: input.name,
|
||||
ownerPubkey: identity.pubkey,
|
||||
projectChannelId: channel.id,
|
||||
projectVisibility: input.projectVisibility ?? "listed",
|
||||
});
|
||||
const existingRepositoryEvent = await fetchOwnHead(
|
||||
KIND_REPO_ANNOUNCEMENT,
|
||||
ownerPubkey,
|
||||
templates.dtag,
|
||||
);
|
||||
const projectEvent = await signRelayEvent(templates.project);
|
||||
await publishProjectEvent(projectEvent);
|
||||
|
||||
let repositoryEvent = existingRepositoryEvent;
|
||||
if (!repositoryEvent) {
|
||||
repositoryEvent = await signRelayEvent(templates.repository);
|
||||
await publishRepositoryEvent(repositoryEvent);
|
||||
}
|
||||
|
||||
const project = readCreatedProject(projectEvent, repositoryEvent);
|
||||
return finishCreate(channel, project, input, resume, projectId);
|
||||
}
|
||||
@@ -172,9 +172,11 @@ export async function fetchProjects(
|
||||
signal?: AbortSignal,
|
||||
): Promise<Project[]> {
|
||||
// Delegates to `buildProjectsFromFetcher` in `projectEnumeration.ts`, which
|
||||
// is the pure, Tauri-free core of this operation. That helper's javadoc
|
||||
// explains the fail-closed tombstone contract and the NIP-OA owner-deletion
|
||||
// relay-side-suppression decision.
|
||||
// is the pure, Tauri-free core of this operation. Its javadoc explains
|
||||
// fail-closed tombstones and NIP-OA owner-deletion suppression.
|
||||
const viewerPubkey = await getIdentity()
|
||||
.then((identity) => identity.pubkey)
|
||||
.catch(() => undefined);
|
||||
const fetcher: FetchProjectEventsExhaustively =
|
||||
fetchExhaustively ??
|
||||
((kinds, extraFilter) =>
|
||||
@@ -182,6 +184,7 @@ export async function fetchProjects(
|
||||
return buildProjectsFromFetcher(fetcher, {
|
||||
relayOrigin: getCachedRelayOrigin(),
|
||||
hiddenAddresses: new Set(readHiddenProjectCards()),
|
||||
viewerPubkey,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
135
desktop/src/features/projects/lib/projectCollection.test.mjs
Normal file
135
desktop/src/features/projects/lib/projectCollection.test.mjs
Normal file
@@ -0,0 +1,135 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
absorbStandaloneProjectRepositories,
|
||||
homeRepositoriesToBind,
|
||||
} from "./projectCollection.ts";
|
||||
|
||||
const OWNER = "a".repeat(64);
|
||||
const AGENT = "b".repeat(64);
|
||||
const CHANNEL = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function explicitProject(overrides = {}) {
|
||||
return {
|
||||
id: `30621:${OWNER}:space-invaders-3d`,
|
||||
dtag: "space-invaders-3d",
|
||||
name: "Space Invaders 3D",
|
||||
description: "Recreating Space Invaders the Game but in 3D",
|
||||
owner: OWNER,
|
||||
createdAt: 100,
|
||||
projectChannelId: CHANNEL,
|
||||
relatedChannelIds: [],
|
||||
status: "active",
|
||||
projectAddress: `30621:${OWNER}:space-invaders-3d`,
|
||||
primaryRepositoryAddress: null,
|
||||
repositoryAddresses: [],
|
||||
repositoryRelayHints: {},
|
||||
repositories: [],
|
||||
unavailableRepositoryAddresses: [],
|
||||
visibility: "listed",
|
||||
legacy: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function standaloneRepo(overrides = {}) {
|
||||
const owner = overrides.owner ?? AGENT;
|
||||
const dtag = overrides.dtag ?? "space-invaders-3d";
|
||||
const repoAddress = `30617:${owner}:${dtag}`;
|
||||
const repository = {
|
||||
id: `${owner}:${dtag}`,
|
||||
dtag,
|
||||
name: "Space Invaders 3D",
|
||||
description: "A 3D remake of Space Invaders built with three.js",
|
||||
cloneUrls: [],
|
||||
webUrl: null,
|
||||
owner,
|
||||
contributors: [owner],
|
||||
createdAt: 200,
|
||||
status: "active",
|
||||
defaultBranch: "main",
|
||||
repoAddress,
|
||||
channelId: CHANNEL,
|
||||
...overrides.repository,
|
||||
};
|
||||
return {
|
||||
id: repoAddress,
|
||||
dtag,
|
||||
name: repository.name,
|
||||
description: repository.description,
|
||||
owner,
|
||||
createdAt: 200,
|
||||
projectChannelId: null,
|
||||
relatedChannelIds: [],
|
||||
status: "active",
|
||||
projectAddress: repoAddress,
|
||||
primaryRepositoryAddress: repoAddress,
|
||||
repositoryAddresses: [repoAddress],
|
||||
repositoryRelayHints: {},
|
||||
repositories: [repository],
|
||||
unavailableRepositoryAddresses: [],
|
||||
visibility: "listed",
|
||||
legacy: true,
|
||||
...overrides.card,
|
||||
};
|
||||
}
|
||||
|
||||
test("absorbStandaloneProjectRepositories folds a home-channel repo into the project", () => {
|
||||
const project = explicitProject();
|
||||
const repoCard = standaloneRepo();
|
||||
const folded = absorbStandaloneProjectRepositories([project, repoCard]);
|
||||
|
||||
assert.equal(folded.length, 1);
|
||||
assert.equal(folded[0].legacy, false);
|
||||
assert.equal(folded[0].repositories.length, 1);
|
||||
assert.equal(folded[0].repositories[0].repoAddress, repoCard.projectAddress);
|
||||
assert.equal(folded[0].repositoryAddresses[0], repoCard.projectAddress);
|
||||
});
|
||||
|
||||
test("absorbStandaloneProjectRepositories folds the owner's same-slug repo", () => {
|
||||
const project = explicitProject();
|
||||
const repoCard = standaloneRepo({
|
||||
owner: OWNER,
|
||||
repository: { channelId: null },
|
||||
});
|
||||
const folded = absorbStandaloneProjectRepositories([project, repoCard]);
|
||||
|
||||
assert.equal(folded.length, 1);
|
||||
assert.equal(folded[0].repositories[0].owner, OWNER);
|
||||
});
|
||||
|
||||
test("absorbStandaloneProjectRepositories keeps an unrelated standalone repo", () => {
|
||||
const project = explicitProject();
|
||||
const repoCard = standaloneRepo({
|
||||
dtag: "other-game",
|
||||
owner: AGENT,
|
||||
repository: { channelId: null, dtag: "other-game" },
|
||||
});
|
||||
const folded = absorbStandaloneProjectRepositories([project, repoCard]);
|
||||
|
||||
assert.equal(folded.length, 2);
|
||||
assert.equal(
|
||||
folded.some((item) => item.legacy),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("homeRepositoriesToBind lists absorbed channel repos missing from the signed project", () => {
|
||||
const project = explicitProject({
|
||||
repositories: [standaloneRepo().repositories[0]],
|
||||
repositoryAddresses: [standaloneRepo().projectAddress],
|
||||
});
|
||||
const pending = homeRepositoriesToBind(project, []);
|
||||
assert.equal(pending.length, 1);
|
||||
assert.equal(pending[0].repoAddress, standaloneRepo().projectAddress);
|
||||
});
|
||||
|
||||
test("homeRepositoriesToBind ignores repos already on the signed project", () => {
|
||||
const repo = standaloneRepo().repositories[0];
|
||||
const project = explicitProject({
|
||||
repositories: [repo],
|
||||
repositoryAddresses: [repo.repoAddress],
|
||||
});
|
||||
assert.equal(homeRepositoriesToBind(project, [repo.repoAddress]).length, 0);
|
||||
});
|
||||
103
desktop/src/features/projects/lib/projectCollection.ts
Normal file
103
desktop/src/features/projects/lib/projectCollection.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { Project, Repository } from "@/features/projects/projectModels";
|
||||
|
||||
function withAbsorbedRepository(
|
||||
project: Project,
|
||||
repository: Repository,
|
||||
): Project {
|
||||
if (project.repositoryAddresses.includes(repository.repoAddress)) {
|
||||
return project;
|
||||
}
|
||||
return {
|
||||
...project,
|
||||
primaryRepositoryAddress:
|
||||
project.primaryRepositoryAddress ?? repository.repoAddress,
|
||||
repositories: [...project.repositories, repository],
|
||||
repositoryAddresses: [
|
||||
...project.repositoryAddresses,
|
||||
repository.repoAddress,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function hostForStandaloneRepository(
|
||||
explicitProjects: Project[],
|
||||
repository: Repository,
|
||||
): Project | undefined {
|
||||
const channelHost = repository.channelId
|
||||
? explicitProjects.find(
|
||||
(project) => project.projectChannelId === repository.channelId,
|
||||
)
|
||||
: undefined;
|
||||
if (channelHost) return channelHost;
|
||||
return explicitProjects.find(
|
||||
(project) =>
|
||||
project.owner === repository.owner && project.dtag === repository.dtag,
|
||||
);
|
||||
}
|
||||
|
||||
function repositoryBelongsOnProjectHome(
|
||||
project: Project,
|
||||
repository: Repository,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
(repository.channelId &&
|
||||
repository.channelId === project.projectChannelId) ||
|
||||
(repository.owner === project.owner && repository.dtag === project.dtag),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repositories already shown on the project (after absorb) that are not yet
|
||||
* on the signed `kind:30621` `a` tag set. The owner should bind them so
|
||||
* other clients see the same grouping.
|
||||
*/
|
||||
export function homeRepositoriesToBind(
|
||||
project: Project,
|
||||
signedAddresses: ReadonlyArray<string> | ReadonlySet<string>,
|
||||
): Repository[] {
|
||||
const signed = new Set(signedAddresses);
|
||||
return project.repositories.filter(
|
||||
(repository) =>
|
||||
!signed.has(repository.repoAddress) &&
|
||||
repositoryBelongsOnProjectHome(project, repository),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After the NIP-MP fold, keep a repository off the standalone-project list
|
||||
* when it already belongs to a listing-eligible project's home channel, or
|
||||
* when the same owner already has an explicit project with that slug.
|
||||
*
|
||||
* Agents often announce a repo (`repos create --channel`) without
|
||||
* `projects add-repo`. Without this, the same work shows up as a second card.
|
||||
*/
|
||||
export function absorbStandaloneProjectRepositories(
|
||||
projects: Project[],
|
||||
): Project[] {
|
||||
const explicitProjects = projects.filter((project) => !project.legacy);
|
||||
if (explicitProjects.length === 0) return projects;
|
||||
|
||||
const absorbed = new Set<string>();
|
||||
let nextExplicit = explicitProjects;
|
||||
for (const card of projects) {
|
||||
if (!card.legacy) continue;
|
||||
const repository = card.repositories[0];
|
||||
if (!repository) continue;
|
||||
const host = hostForStandaloneRepository(nextExplicit, repository);
|
||||
if (!host) continue;
|
||||
absorbed.add(card.projectAddress);
|
||||
nextExplicit = nextExplicit.map((project) =>
|
||||
project.projectAddress === host.projectAddress
|
||||
? withAbsorbedRepository(project, repository)
|
||||
: project,
|
||||
);
|
||||
}
|
||||
|
||||
if (absorbed.size === 0) return projects;
|
||||
return [
|
||||
...nextExplicit,
|
||||
...projects.filter(
|
||||
(project) => project.legacy && !absorbed.has(project.projectAddress),
|
||||
),
|
||||
];
|
||||
}
|
||||
105
desktop/src/features/projects/projectChannelCreation.test.mjs
Normal file
105
desktop/src/features/projects/projectChannelCreation.test.mjs
Normal file
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { buildProjectRelatedChannelPatchTemplate } from "./projectChannelCreation.ts";
|
||||
import {
|
||||
MAX_PROJECT_RELATED_CHANNELS,
|
||||
PROJECT_RELATED_CHANNEL_TAG,
|
||||
} from "./projectModels.ts";
|
||||
|
||||
const OWNER = "a".repeat(64);
|
||||
const OTHER = "b".repeat(64);
|
||||
const CHANNEL_A = "11111111-1111-4111-8111-111111111111";
|
||||
const CHANNEL_B = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
function liveHead(tags = []) {
|
||||
return {
|
||||
content: "",
|
||||
created_at: 100,
|
||||
id: "project-head",
|
||||
kind: 30621,
|
||||
pubkey: OWNER,
|
||||
sig: "sig",
|
||||
tags: [
|
||||
["d", "sprout"],
|
||||
["name", "Sprout"],
|
||||
["buzz-channel", CHANNEL_A],
|
||||
...tags,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test("appends a related channel tag and preserves the live head", () => {
|
||||
const patched = buildProjectRelatedChannelPatchTemplate({
|
||||
channelId: CHANNEL_B,
|
||||
liveHead: liveHead([["description", "A project"]]),
|
||||
ownerPubkey: OWNER,
|
||||
});
|
||||
|
||||
assert.equal(patched.alreadyBound, false);
|
||||
assert.deepEqual(patched.project.tags, [
|
||||
["d", "sprout"],
|
||||
["name", "Sprout"],
|
||||
["buzz-channel", CHANNEL_A],
|
||||
["description", "A project"],
|
||||
[PROJECT_RELATED_CHANNEL_TAG, CHANNEL_B],
|
||||
]);
|
||||
});
|
||||
|
||||
test("is idempotent when the related channel is already tagged", () => {
|
||||
const patched = buildProjectRelatedChannelPatchTemplate({
|
||||
channelId: CHANNEL_B,
|
||||
liveHead: liveHead([[PROJECT_RELATED_CHANNEL_TAG, CHANNEL_B]]),
|
||||
ownerPubkey: OWNER,
|
||||
});
|
||||
|
||||
assert.equal(patched.alreadyBound, true);
|
||||
assert.equal(
|
||||
patched.project.tags.filter((tag) => tag[0] === PROJECT_RELATED_CHANNEL_TAG)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses to bind the home channel as a related channel", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
buildProjectRelatedChannelPatchTemplate({
|
||||
channelId: CHANNEL_A,
|
||||
liveHead: liveHead(),
|
||||
ownerPubkey: OWNER,
|
||||
}),
|
||||
/already this project's home/,
|
||||
);
|
||||
});
|
||||
|
||||
test("only the project owner can add related channels", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
buildProjectRelatedChannelPatchTemplate({
|
||||
channelId: CHANNEL_B,
|
||||
liveHead: liveHead(),
|
||||
ownerPubkey: OTHER,
|
||||
}),
|
||||
/Only the project owner/,
|
||||
);
|
||||
});
|
||||
|
||||
test("caps extra related channels", () => {
|
||||
const tags = Array.from(
|
||||
{ length: MAX_PROJECT_RELATED_CHANNELS },
|
||||
(_, index) => {
|
||||
const suffix = String(index + 1).padStart(12, "0");
|
||||
return [PROJECT_RELATED_CHANNEL_TAG, `33333333-3333-4333-8333-${suffix}`];
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
buildProjectRelatedChannelPatchTemplate({
|
||||
channelId: CHANNEL_B,
|
||||
liveHead: liveHead(tags),
|
||||
ownerPubkey: OWNER,
|
||||
}),
|
||||
/extra channels/,
|
||||
);
|
||||
});
|
||||
71
desktop/src/features/projects/projectChannelCreation.ts
Normal file
71
desktop/src/features/projects/projectChannelCreation.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
import { KIND_PROJECT_ANNOUNCEMENT } from "@/shared/constants/kinds";
|
||||
import {
|
||||
isValidProjectChannelId,
|
||||
MAX_PROJECT_RELATED_CHANNELS,
|
||||
PROJECT_RELATED_CHANNEL_TAG,
|
||||
validateProjectEventEnvelope,
|
||||
} from "@/features/projects/projectModels";
|
||||
import type { ProjectEventTemplate } from "./projectCreation";
|
||||
|
||||
/**
|
||||
* Appends a `buzz-related-channel` tag to a live project head. Every other
|
||||
* tag is preserved so adding a stream cannot erase unknown metadata.
|
||||
*/
|
||||
export function buildProjectRelatedChannelPatchTemplate({
|
||||
channelId,
|
||||
liveHead,
|
||||
ownerPubkey,
|
||||
}: {
|
||||
channelId: string;
|
||||
liveHead: RelayEvent;
|
||||
ownerPubkey: string;
|
||||
}): { alreadyBound: boolean; project: ProjectEventTemplate } {
|
||||
const normalizedOwner = ownerPubkey.trim().toLowerCase();
|
||||
if (normalizedOwner !== liveHead.pubkey.toLowerCase()) {
|
||||
throw new Error("Only the project owner can add channels.");
|
||||
}
|
||||
const normalizedChannelId = channelId.trim();
|
||||
if (!isValidProjectChannelId(normalizedChannelId)) {
|
||||
throw new Error("Project channel is invalid.");
|
||||
}
|
||||
const homeChannelId = liveHead.tags.find(
|
||||
(tag) => tag[0] === "buzz-channel",
|
||||
)?.[1];
|
||||
if (homeChannelId === normalizedChannelId) {
|
||||
throw new Error("That channel is already this project's home.");
|
||||
}
|
||||
const existingRelated = liveHead.tags
|
||||
.filter((tag) => tag[0] === PROJECT_RELATED_CHANNEL_TAG)
|
||||
.map((tag) => tag[1])
|
||||
.filter((value): value is string => Boolean(value));
|
||||
if (existingRelated.includes(normalizedChannelId)) {
|
||||
validateProjectEventEnvelope(liveHead.tags, liveHead.content);
|
||||
return {
|
||||
alreadyBound: true,
|
||||
project: {
|
||||
kind: KIND_PROJECT_ANNOUNCEMENT,
|
||||
content: liveHead.content,
|
||||
tags: liveHead.tags.map((tag) => [...tag]),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (existingRelated.length >= MAX_PROJECT_RELATED_CHANNELS) {
|
||||
throw new Error(
|
||||
`A project cannot contain more than ${MAX_PROJECT_RELATED_CHANNELS} extra channels.`,
|
||||
);
|
||||
}
|
||||
const tags = [
|
||||
...liveHead.tags.map((tag) => [...tag]),
|
||||
[PROJECT_RELATED_CHANNEL_TAG, normalizedChannelId],
|
||||
];
|
||||
validateProjectEventEnvelope(tags, liveHead.content);
|
||||
return {
|
||||
alreadyBound: false,
|
||||
project: {
|
||||
kind: KIND_PROJECT_ANNOUNCEMENT,
|
||||
content: liveHead.content,
|
||||
tags,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,77 +2,198 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildInitialProjectEventTemplates,
|
||||
buildDefaultProjectRepositoryTemplate,
|
||||
buildProjectAnnouncementTemplate,
|
||||
buildProjectBootstrapTemplates,
|
||||
conflictingListedProject,
|
||||
isUnsupportedProjectKindError,
|
||||
} from "./projectCreation.ts";
|
||||
|
||||
const OWNER = "a".repeat(64);
|
||||
const CHANNEL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
|
||||
test("buildInitialProjectEventTemplates emits a NIP-MP project", () => {
|
||||
const templates = buildInitialProjectEventTemplates({
|
||||
accessChannelId: CHANNEL,
|
||||
cloneUrl: "https://relay.example/git/owner/sprout.git",
|
||||
description: "A multi-repository workspace",
|
||||
test("buildProjectAnnouncementTemplate emits a channel-first NIP-MP project", () => {
|
||||
const templates = buildProjectAnnouncementTemplate({
|
||||
description: "A workspace that starts as a conversation",
|
||||
name: "Sprout",
|
||||
ownerPubkey: OWNER,
|
||||
webUrl: "https://example.com/sprout",
|
||||
projectChannelId: CHANNEL,
|
||||
});
|
||||
|
||||
assert.equal(templates.dtag, "sprout");
|
||||
assert.equal(templates.project.kind, 30621);
|
||||
assert.deepEqual(templates.project.tags, [
|
||||
["d", "sprout"],
|
||||
["name", "Sprout"],
|
||||
["buzz-channel", CHANNEL],
|
||||
["description", "A workspace that starts as a conversation"],
|
||||
]);
|
||||
assert.equal(templates.project.content, "");
|
||||
assert.equal(
|
||||
templates.project.tags.some((tag) => tag[0] === "a"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildProjectAnnouncementTemplate records unlisted visibility and members", () => {
|
||||
const address = `30617:${OWNER}:sprout`;
|
||||
const templates = buildProjectAnnouncementTemplate({
|
||||
name: "Sprout",
|
||||
ownerPubkey: OWNER,
|
||||
projectChannelId: CHANNEL,
|
||||
projectVisibility: "unlisted",
|
||||
repositoryAddresses: [address],
|
||||
});
|
||||
|
||||
assert.deepEqual(templates.project.tags, [
|
||||
["d", "sprout"],
|
||||
["name", "Sprout"],
|
||||
["buzz-channel", CHANNEL],
|
||||
["buzz-visibility", "unlisted"],
|
||||
["a", address],
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildProjectBootstrapTemplates binds a default repository to the home channel", () => {
|
||||
const templates = buildProjectBootstrapTemplates({
|
||||
description: "A workspace that starts as a conversation",
|
||||
name: "Sprout",
|
||||
ownerPubkey: OWNER,
|
||||
projectChannelId: CHANNEL,
|
||||
});
|
||||
const repositoryAddress = `30617:${OWNER}:sprout`;
|
||||
|
||||
assert.equal(templates.dtag, "sprout");
|
||||
assert.equal(templates.repositoryAddress, repositoryAddress);
|
||||
assert.equal(templates.project.kind, 30621);
|
||||
assert.equal(templates.repository.kind, 30617);
|
||||
assert.deepEqual(templates.project.tags, [
|
||||
["d", "sprout"],
|
||||
["name", "Sprout"],
|
||||
["buzz-channel", CHANNEL],
|
||||
["description", "A multi-repository workspace"],
|
||||
["a", `30617:${OWNER}:sprout`],
|
||||
["description", "A workspace that starts as a conversation"],
|
||||
["a", repositoryAddress],
|
||||
]);
|
||||
assert.equal(templates.project.content, "");
|
||||
assert.deepEqual(templates.repository.tags, [
|
||||
["d", "sprout"],
|
||||
["name", "Sprout"],
|
||||
["buzz-channel", CHANNEL],
|
||||
["description", "A multi-repository workspace"],
|
||||
["clone", "https://relay.example/git/owner/sprout.git"],
|
||||
["web", "https://example.com/sprout"],
|
||||
["description", "A workspace that starts as a conversation"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildInitialProjectEventTemplates rejects names without an identifier", () => {
|
||||
test("buildDefaultProjectRepositoryTemplate uses the project slug as the repo id", () => {
|
||||
const template = buildDefaultProjectRepositoryTemplate({
|
||||
name: "Space Invaders 3D",
|
||||
ownerPubkey: OWNER,
|
||||
projectChannelId: CHANNEL,
|
||||
});
|
||||
|
||||
assert.equal(template.dtag, "space-invaders-3d");
|
||||
assert.equal(template.repositoryAddress, `30617:${OWNER}:space-invaders-3d`);
|
||||
});
|
||||
|
||||
test("buildProjectAnnouncementTemplate rejects names without an identifier", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
buildInitialProjectEventTemplates({
|
||||
accessChannelId: CHANNEL,
|
||||
buildProjectAnnouncementTemplate({
|
||||
name: "!!!",
|
||||
ownerPubkey: OWNER,
|
||||
projectChannelId: CHANNEL,
|
||||
}),
|
||||
/letters or numbers/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildInitialProjectEventTemplates enforces the description tag byte limit", () => {
|
||||
test("buildProjectAnnouncementTemplate enforces the description tag byte limit", () => {
|
||||
assert.doesNotThrow(() =>
|
||||
buildInitialProjectEventTemplates({
|
||||
accessChannelId: CHANNEL,
|
||||
buildProjectAnnouncementTemplate({
|
||||
description: "🙂".repeat(512),
|
||||
name: "Sprout",
|
||||
ownerPubkey: OWNER,
|
||||
projectChannelId: CHANNEL,
|
||||
}),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
buildInitialProjectEventTemplates({
|
||||
accessChannelId: CHANNEL,
|
||||
buildProjectAnnouncementTemplate({
|
||||
description: "🙂".repeat(513),
|
||||
name: "Sprout",
|
||||
ownerPubkey: OWNER,
|
||||
projectChannelId: CHANNEL,
|
||||
}),
|
||||
/2,048 bytes/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildProjectAnnouncementTemplate rejects an invalid project channel", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
buildProjectAnnouncementTemplate({
|
||||
name: "Sprout",
|
||||
ownerPubkey: OWNER,
|
||||
projectChannelId: "not-a-channel",
|
||||
}),
|
||||
/Project channel is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("conflictingListedProject ignores the caller's own slug and legacy cards", () => {
|
||||
assert.equal(
|
||||
conflictingListedProject(
|
||||
[
|
||||
{
|
||||
dtag: "sprout",
|
||||
legacy: false,
|
||||
name: "Sprout",
|
||||
owner: OWNER,
|
||||
},
|
||||
],
|
||||
{ dtag: "sprout", name: "Sprout", ownerPubkey: OWNER },
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
conflictingListedProject(
|
||||
[
|
||||
{
|
||||
dtag: "sprout",
|
||||
legacy: true,
|
||||
name: "Sprout",
|
||||
owner: "b".repeat(64),
|
||||
},
|
||||
],
|
||||
{ dtag: "sprout", name: "Sprout", ownerPubkey: OWNER },
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("conflictingListedProject blocks another listed project with the same name or slug", () => {
|
||||
const other = {
|
||||
dtag: "space-invaders-3d",
|
||||
legacy: false,
|
||||
name: "Space Invaders 3D",
|
||||
owner: "b".repeat(64),
|
||||
};
|
||||
assert.deepEqual(
|
||||
conflictingListedProject([other], {
|
||||
dtag: "space-invaders-3d",
|
||||
name: "Space Invaders 3D",
|
||||
ownerPubkey: OWNER,
|
||||
}),
|
||||
other,
|
||||
);
|
||||
assert.deepEqual(
|
||||
conflictingListedProject([other], {
|
||||
dtag: "space-invaders-3d-remake",
|
||||
name: "Space Invaders 3D",
|
||||
ownerPubkey: OWNER,
|
||||
}),
|
||||
other,
|
||||
);
|
||||
});
|
||||
|
||||
test("isUnsupportedProjectKindError recognizes relay kind compatibility failures", () => {
|
||||
assert.equal(
|
||||
isUnsupportedProjectKindError(
|
||||
|
||||
@@ -10,13 +10,18 @@ export type ProjectEventTemplate = {
|
||||
tags: string[][];
|
||||
};
|
||||
|
||||
export type InitialProjectEventTemplates = {
|
||||
export type ProjectAnnouncementTemplate = {
|
||||
dtag: string;
|
||||
project: ProjectEventTemplate;
|
||||
};
|
||||
|
||||
export type ProjectBootstrapTemplates = ProjectAnnouncementTemplate & {
|
||||
repository: ProjectEventTemplate;
|
||||
repositoryAddress: string;
|
||||
};
|
||||
|
||||
export type ProjectListingVisibility = "listed" | "unlisted";
|
||||
|
||||
export function isUnsupportedProjectKindError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
@@ -24,28 +29,64 @@ export function isUnsupportedProjectKindError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function projectDtagFromName(name: string): string {
|
||||
export function projectDtagFromName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function buildInitialProjectEventTemplates({
|
||||
accessChannelId,
|
||||
cloneUrl,
|
||||
export type ListedProjectIdentity = {
|
||||
dtag: string;
|
||||
legacy: boolean;
|
||||
name: string;
|
||||
owner: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A second listed project with the same slug or display name is the duplicate
|
||||
* card users see when an agent runs `projects create` inside an existing
|
||||
* project. Same-owner + same-slug is handled as resume/idempotent create by
|
||||
* the caller; this finds a *different* listed project that should block create.
|
||||
*/
|
||||
export function conflictingListedProject(
|
||||
projects: readonly ListedProjectIdentity[],
|
||||
input: { dtag: string; name: string; ownerPubkey: string },
|
||||
): ListedProjectIdentity | null {
|
||||
const ownerPubkey = input.ownerPubkey.toLowerCase();
|
||||
const normalizedName = input.name.trim().toLowerCase();
|
||||
return (
|
||||
projects.find((project) => {
|
||||
if (project.legacy) return false;
|
||||
const sameOwnerSlug =
|
||||
project.owner.toLowerCase() === ownerPubkey &&
|
||||
project.dtag === input.dtag;
|
||||
if (sameOwnerSlug) return false;
|
||||
return (
|
||||
project.dtag === input.dtag ||
|
||||
project.name.trim().toLowerCase() === normalizedName
|
||||
);
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeProjectAnnouncementInput({
|
||||
description,
|
||||
name,
|
||||
ownerPubkey,
|
||||
webUrl,
|
||||
projectChannelId,
|
||||
}: {
|
||||
accessChannelId: string;
|
||||
cloneUrl?: string;
|
||||
description?: string;
|
||||
name: string;
|
||||
ownerPubkey: string;
|
||||
webUrl?: string;
|
||||
}): InitialProjectEventTemplates {
|
||||
projectChannelId: string;
|
||||
}): {
|
||||
dtag: string;
|
||||
normalizedDescription: string;
|
||||
normalizedName: string;
|
||||
normalizedOwner: string;
|
||||
normalizedProjectChannelId: string;
|
||||
} {
|
||||
const normalizedName = name.trim();
|
||||
if (!normalizedName) {
|
||||
throw new Error("Project name is required.");
|
||||
@@ -66,36 +107,74 @@ export function buildInitialProjectEventTemplates({
|
||||
if (new TextEncoder().encode(normalizedDescription).byteLength > 2_048) {
|
||||
throw new Error("Project description must not exceed 2,048 bytes.");
|
||||
}
|
||||
const repositoryTags: string[][] = [
|
||||
["d", dtag],
|
||||
["name", normalizedName],
|
||||
];
|
||||
const normalizedProjectChannelId = projectChannelId.trim();
|
||||
if (!isValidProjectChannelId(normalizedProjectChannelId)) {
|
||||
throw new Error("Project channel is invalid.");
|
||||
}
|
||||
|
||||
return {
|
||||
dtag,
|
||||
normalizedDescription,
|
||||
normalizedName,
|
||||
normalizedOwner,
|
||||
normalizedProjectChannelId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Channel-first NIP-MP project: metadata, home channel, optional members. */
|
||||
export function buildProjectAnnouncementTemplate({
|
||||
description,
|
||||
name,
|
||||
ownerPubkey,
|
||||
projectChannelId,
|
||||
projectVisibility = "listed",
|
||||
repositoryAddresses = [],
|
||||
}: {
|
||||
description?: string;
|
||||
name: string;
|
||||
ownerPubkey: string;
|
||||
projectChannelId: string;
|
||||
projectVisibility?: ProjectListingVisibility;
|
||||
repositoryAddresses?: readonly string[];
|
||||
}): ProjectAnnouncementTemplate {
|
||||
const {
|
||||
dtag,
|
||||
normalizedDescription,
|
||||
normalizedName,
|
||||
normalizedProjectChannelId,
|
||||
} = normalizeProjectAnnouncementInput({
|
||||
description,
|
||||
name,
|
||||
ownerPubkey,
|
||||
projectChannelId,
|
||||
});
|
||||
|
||||
if (new Set(repositoryAddresses).size !== repositoryAddresses.length) {
|
||||
throw new Error("A project cannot contain duplicate repositories.");
|
||||
}
|
||||
if (
|
||||
repositoryAddresses.some(
|
||||
(address) => !/^30617:[0-9a-f]{64}:.+$/.test(address),
|
||||
)
|
||||
) {
|
||||
throw new Error("Repository address is invalid.");
|
||||
}
|
||||
|
||||
const projectTags: string[][] = [
|
||||
["d", dtag],
|
||||
["name", normalizedName],
|
||||
["buzz-channel", normalizedProjectChannelId],
|
||||
];
|
||||
const normalizedAccessChannelId = accessChannelId.trim();
|
||||
if (!isValidProjectChannelId(normalizedAccessChannelId)) {
|
||||
throw new Error("Repository access channel is invalid.");
|
||||
}
|
||||
repositoryTags.push(["buzz-channel", normalizedAccessChannelId]);
|
||||
projectTags.push(["buzz-channel", normalizedAccessChannelId]);
|
||||
if (normalizedDescription) {
|
||||
repositoryTags.push(["description", normalizedDescription]);
|
||||
projectTags.push(["description", normalizedDescription]);
|
||||
}
|
||||
const normalizedCloneUrl = cloneUrl?.trim();
|
||||
if (normalizedCloneUrl) {
|
||||
repositoryTags.push(["clone", normalizedCloneUrl]);
|
||||
if (projectVisibility === "unlisted") {
|
||||
projectTags.push(["buzz-visibility", "unlisted"]);
|
||||
}
|
||||
const normalizedWebUrl = webUrl?.trim();
|
||||
if (normalizedWebUrl) {
|
||||
repositoryTags.push(["web", normalizedWebUrl]);
|
||||
for (const address of [...repositoryAddresses].sort()) {
|
||||
projectTags.push(["a", address]);
|
||||
}
|
||||
|
||||
const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${dtag}`;
|
||||
projectTags.push(["a", repositoryAddress]);
|
||||
|
||||
return {
|
||||
dtag,
|
||||
project: {
|
||||
@@ -103,11 +182,88 @@ export function buildInitialProjectEventTemplates({
|
||||
content: "",
|
||||
tags: projectTags,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Default 30617 bound to the project home channel, using the project slug. */
|
||||
export function buildDefaultProjectRepositoryTemplate({
|
||||
description,
|
||||
name,
|
||||
ownerPubkey,
|
||||
projectChannelId,
|
||||
}: {
|
||||
description?: string;
|
||||
name: string;
|
||||
ownerPubkey: string;
|
||||
projectChannelId: string;
|
||||
}): {
|
||||
dtag: string;
|
||||
repository: ProjectEventTemplate;
|
||||
repositoryAddress: string;
|
||||
} {
|
||||
const {
|
||||
dtag,
|
||||
normalizedDescription,
|
||||
normalizedName,
|
||||
normalizedOwner,
|
||||
normalizedProjectChannelId,
|
||||
} = normalizeProjectAnnouncementInput({
|
||||
description,
|
||||
name,
|
||||
ownerPubkey,
|
||||
projectChannelId,
|
||||
});
|
||||
const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${dtag}`;
|
||||
const repositoryTags: string[][] = [
|
||||
["d", dtag],
|
||||
["name", normalizedName],
|
||||
["buzz-channel", normalizedProjectChannelId],
|
||||
];
|
||||
if (normalizedDescription) {
|
||||
repositoryTags.push(["description", normalizedDescription]);
|
||||
}
|
||||
return {
|
||||
dtag,
|
||||
repositoryAddress,
|
||||
repository: {
|
||||
kind: KIND_REPO_ANNOUNCEMENT,
|
||||
content: normalizedDescription,
|
||||
tags: repositoryTags,
|
||||
},
|
||||
repositoryAddress,
|
||||
};
|
||||
}
|
||||
|
||||
/** Home channel + default repository already listed on the project. */
|
||||
export function buildProjectBootstrapTemplates({
|
||||
description,
|
||||
name,
|
||||
ownerPubkey,
|
||||
projectChannelId,
|
||||
projectVisibility = "listed",
|
||||
}: {
|
||||
description?: string;
|
||||
name: string;
|
||||
ownerPubkey: string;
|
||||
projectChannelId: string;
|
||||
projectVisibility?: ProjectListingVisibility;
|
||||
}): ProjectBootstrapTemplates {
|
||||
const repository = buildDefaultProjectRepositoryTemplate({
|
||||
description,
|
||||
name,
|
||||
ownerPubkey,
|
||||
projectChannelId,
|
||||
});
|
||||
const announcement = buildProjectAnnouncementTemplate({
|
||||
description,
|
||||
name,
|
||||
ownerPubkey,
|
||||
projectChannelId,
|
||||
projectVisibility,
|
||||
repositoryAddresses: [repository.repositoryAddress],
|
||||
});
|
||||
return {
|
||||
...announcement,
|
||||
repository: repository.repository,
|
||||
repositoryAddress: repository.repositoryAddress,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
KIND_PROJECT_ANNOUNCEMENT,
|
||||
KIND_REPO_ANNOUNCEMENT,
|
||||
} from "@/shared/constants/kinds";
|
||||
import { absorbStandaloneProjectRepositories } from "./lib/projectCollection";
|
||||
import { buildProjectReadModels, type Project } from "./projectModels";
|
||||
|
||||
const PROJECT_ENUMERATION_PAGE_SIZE = 500;
|
||||
@@ -173,6 +174,7 @@ export async function buildProjectsFromFetcher(
|
||||
options: {
|
||||
relayOrigin?: string | null;
|
||||
hiddenAddresses?: ReadonlySet<string>;
|
||||
viewerPubkey?: string | null;
|
||||
} = {},
|
||||
): Promise<Project[]> {
|
||||
const [projectEvents, repositoryEvents] = await Promise.all([
|
||||
@@ -200,11 +202,14 @@ export async function buildProjectsFromFetcher(
|
||||
);
|
||||
}
|
||||
|
||||
return buildProjectReadModels({
|
||||
projectEvents,
|
||||
repositoryEvents,
|
||||
deletionEvents: tombstoneResult.events,
|
||||
relayOrigin: options.relayOrigin ?? null,
|
||||
hiddenAddresses: options.hiddenAddresses ?? new Set(),
|
||||
}).sort((a, b) => b.createdAt - a.createdAt);
|
||||
return absorbStandaloneProjectRepositories(
|
||||
buildProjectReadModels({
|
||||
projectEvents,
|
||||
repositoryEvents,
|
||||
deletionEvents: tombstoneResult.events,
|
||||
relayOrigin: options.relayOrigin ?? null,
|
||||
hiddenAddresses: options.hiddenAddresses ?? new Set(),
|
||||
viewerPubkey: options.viewerPubkey,
|
||||
}),
|
||||
).sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,31 @@ test("buildProjectReadModels resolves repositories with a deterministic selectio
|
||||
projects[0].repositoryRelayHints[backendAddress],
|
||||
"wss://relay.example",
|
||||
);
|
||||
assert.equal(
|
||||
projects[0].projectChannelId,
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
);
|
||||
assert.deepEqual(projects[0].relatedChannelIds, []);
|
||||
});
|
||||
|
||||
test("buildProjectReadModels keeps extra related channel ids", () => {
|
||||
const relatedA = "22222222-2222-4222-8222-222222222222";
|
||||
const relatedB = "33333333-3333-4333-8333-333333333333";
|
||||
const home = "11111111-1111-4111-8111-111111111111";
|
||||
const projects = buildProjectReadModels({
|
||||
projectEvents: [
|
||||
projectEvent([
|
||||
["buzz-related-channel", relatedA],
|
||||
["buzz-related-channel", home],
|
||||
["buzz-related-channel", relatedB],
|
||||
["buzz-related-channel", relatedA],
|
||||
]),
|
||||
],
|
||||
repositoryEvents: [],
|
||||
relayOrigin: RELAY_ORIGIN,
|
||||
});
|
||||
|
||||
assert.deepEqual(projects[0].relatedChannelIds, [relatedA, relatedB]);
|
||||
});
|
||||
|
||||
test("buildProjectReadModels keeps unclaimed repositories as implicit projects", () => {
|
||||
@@ -194,6 +219,44 @@ test("selectProjectRepository honors a request and falls back to primary", () =>
|
||||
assert.equal(selectProjectRepository(projects[0], null)?.dtag, "backend");
|
||||
});
|
||||
|
||||
test("buildProjectReadModels keeps the viewer's own unlisted project", () => {
|
||||
const repoAddress = `30617:${PROJECT_OWNER}:secret`;
|
||||
const unlisted = {
|
||||
...projectEvent([["a", repoAddress]]),
|
||||
tags: [
|
||||
["d", "secret"],
|
||||
["name", "Secret"],
|
||||
["buzz-channel", "11111111-1111-4111-8111-111111111111"],
|
||||
["buzz-visibility", "unlisted"],
|
||||
["a", repoAddress],
|
||||
],
|
||||
};
|
||||
const asStranger = buildProjectReadModels({
|
||||
projectEvents: [unlisted],
|
||||
repositoryEvents: [repositoryEvent(PROJECT_OWNER, "secret")],
|
||||
relayOrigin: RELAY_ORIGIN,
|
||||
});
|
||||
const asOwner = buildProjectReadModels({
|
||||
projectEvents: [unlisted],
|
||||
repositoryEvents: [repositoryEvent(PROJECT_OWNER, "secret")],
|
||||
relayOrigin: RELAY_ORIGIN,
|
||||
viewerPubkey: PROJECT_OWNER,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
asStranger.some((project) => project.dtag === "secret" && !project.legacy),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
asStranger.some((project) => project.legacy && project.dtag === "secret"),
|
||||
true,
|
||||
);
|
||||
assert.equal(asOwner.length, 1);
|
||||
assert.equal(asOwner[0]?.legacy, false);
|
||||
assert.equal(asOwner[0]?.dtag, "secret");
|
||||
assert.equal(asOwner[0]?.visibility, "unlisted");
|
||||
});
|
||||
|
||||
function coordinateParts(coordinate) {
|
||||
const first = coordinate.indexOf(":");
|
||||
const second = coordinate.indexOf(":", first + 1);
|
||||
|
||||
@@ -32,6 +32,12 @@ export type Project = {
|
||||
owner: string;
|
||||
createdAt: number;
|
||||
projectChannelId: string | null;
|
||||
/**
|
||||
* Extra streams linked to this project via repeatable
|
||||
* `buzz-related-channel` tags. Client convention: NIP-MP treats the tag as
|
||||
* unrecognized metadata, so older readers ignore it.
|
||||
*/
|
||||
relatedChannelIds: string[];
|
||||
status: string;
|
||||
projectAddress: string;
|
||||
primaryRepositoryAddress: string | null;
|
||||
@@ -50,6 +56,12 @@ type BuildProjectReadModelsInput = {
|
||||
deletionEvents?: RelayEvent[];
|
||||
relayOrigin?: string | null;
|
||||
hiddenAddresses?: ReadonlySet<string>;
|
||||
/**
|
||||
* When set, the viewer's own unlisted projects stay in the collection so the
|
||||
* creator can still open them. Other viewers keep the NIP-MP fold: unlisted
|
||||
* projects are absent and do not claim members.
|
||||
*/
|
||||
viewerPubkey?: string | null;
|
||||
};
|
||||
|
||||
const MAX_D_TAG_BYTES = 1_024;
|
||||
@@ -109,6 +121,12 @@ export function isValidProjectChannelId(value: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Repeatable project tag naming an extra stream besides `buzz-channel`. */
|
||||
export const PROJECT_RELATED_CHANNEL_TAG = "buzz-related-channel";
|
||||
|
||||
/** Cap extra project streams so a tag list cannot grow without bound. */
|
||||
export const MAX_PROJECT_RELATED_CHANNELS = 64;
|
||||
|
||||
const SINGLETON_METADATA_TAGS = [
|
||||
"name",
|
||||
"description",
|
||||
@@ -339,6 +357,16 @@ export function eventToExplicitProject(
|
||||
const visibility =
|
||||
rawVisibility === "unlisted" ? ("unlisted" as const) : ("listed" as const);
|
||||
const channel = getTag(event, "buzz-channel");
|
||||
const projectChannelId =
|
||||
channel && isValidProjectChannelId(channel) ? channel : null;
|
||||
const relatedChannelIds = [
|
||||
...new Set(
|
||||
getAllTags(event, PROJECT_RELATED_CHANNEL_TAG).filter(
|
||||
(channelId) =>
|
||||
isValidProjectChannelId(channelId) && channelId !== projectChannelId,
|
||||
),
|
||||
),
|
||||
].slice(0, MAX_PROJECT_RELATED_CHANNELS);
|
||||
return {
|
||||
id: projectAddress,
|
||||
dtag,
|
||||
@@ -346,8 +374,8 @@ export function eventToExplicitProject(
|
||||
description: getTag(event, "description") ?? "",
|
||||
owner,
|
||||
createdAt: event.created_at,
|
||||
projectChannelId:
|
||||
channel && isValidProjectChannelId(channel) ? channel : null,
|
||||
projectChannelId,
|
||||
relatedChannelIds,
|
||||
status: visibility === "listed" ? "active" : "unlisted",
|
||||
projectAddress,
|
||||
primaryRepositoryAddress,
|
||||
@@ -374,6 +402,7 @@ function repositoryToLegacyProject(repository: Repository): Project {
|
||||
owner: repository.owner,
|
||||
createdAt: repository.createdAt,
|
||||
projectChannelId: null,
|
||||
relatedChannelIds: [],
|
||||
status: repository.status,
|
||||
projectAddress: repository.repoAddress,
|
||||
primaryRepositoryAddress: repository.repoAddress,
|
||||
@@ -417,12 +446,23 @@ function buildDeletionThresholds(
|
||||
return thresholds;
|
||||
}
|
||||
|
||||
function projectIsListingEligible(
|
||||
project: Project,
|
||||
viewerPubkey: string | null | undefined,
|
||||
): boolean {
|
||||
if (project.visibility !== "unlisted") return true;
|
||||
return Boolean(
|
||||
viewerPubkey && project.owner === viewerPubkey.trim().toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildProjectReadModels({
|
||||
projectEvents,
|
||||
repositoryEvents,
|
||||
deletionEvents = [],
|
||||
relayOrigin,
|
||||
hiddenAddresses = new Set(),
|
||||
viewerPubkey,
|
||||
}: BuildProjectReadModelsInput): Project[] {
|
||||
const deletionThresholds = buildDeletionThresholds(deletionEvents);
|
||||
|
||||
@@ -463,7 +503,7 @@ export function buildProjectReadModels({
|
||||
visibleRepositoriesByAddress,
|
||||
);
|
||||
return project &&
|
||||
project.visibility === "listed" &&
|
||||
projectIsListingEligible(project, viewerPubkey) &&
|
||||
!hiddenAddresses.has(project.projectAddress)
|
||||
? [project]
|
||||
: [];
|
||||
@@ -548,3 +588,21 @@ export function addRepositoryToProject(
|
||||
) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns the optimistic read model after linking an extra project stream. */
|
||||
export function addRelatedChannelToProject(
|
||||
project: Project,
|
||||
channelId: string,
|
||||
createdAt: number,
|
||||
): Project {
|
||||
const relatedChannelIds = [
|
||||
...new Set([...(project.relatedChannelIds ?? []), channelId]),
|
||||
].filter(
|
||||
(id) => id !== project.projectChannelId && isValidProjectChannelId(id),
|
||||
);
|
||||
return {
|
||||
...project,
|
||||
createdAt,
|
||||
relatedChannelIds: relatedChannelIds.slice(0, MAX_PROJECT_RELATED_CHANNELS),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ type CreateProjectDialogProps = {
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
/** Modal for publishing a project with its initial NIP-34 repository. */
|
||||
/** Modal for creating a project channel. */
|
||||
export function CreateProjectDialog({
|
||||
isCreating,
|
||||
onCreate,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import type { CreateProjectInput } from "@/features/projects/useCreateProject";
|
||||
import { CreateProjectFormSettings } from "@/features/projects/ui/CreateProjectFormSettings";
|
||||
import { useCreateProjectFormSettings } from "@/features/projects/ui/useCreateProjectFormSettings";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
|
||||
@@ -33,51 +34,35 @@ export function CreateProjectFormContent({
|
||||
}) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [cloneUrl, setCloneUrl] = React.useState("");
|
||||
const [webUrl, setWebUrl] = React.useState("");
|
||||
const [accessChannelId, setAccessChannelId] = React.useState("");
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
||||
const nameInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const channelsQuery = useChannelsQuery({ enabled: active });
|
||||
const accessChannels = React.useMemo(
|
||||
() =>
|
||||
(channelsQuery.data ?? []).filter(
|
||||
(channel) =>
|
||||
channel.isMember &&
|
||||
!channel.archivedAt &&
|
||||
channel.channelType !== "dm",
|
||||
),
|
||||
[channelsQuery.data],
|
||||
);
|
||||
const settings = useCreateProjectFormSettings(active);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) return;
|
||||
setName(initialName);
|
||||
setDescription("");
|
||||
setCloneUrl("");
|
||||
setWebUrl("");
|
||||
setAccessChannelId(accessChannels[0]?.id ?? "");
|
||||
setErrorMessage(null);
|
||||
|
||||
const timerId = globalThis.setTimeout(() => {
|
||||
nameInputRef.current?.focus();
|
||||
}, 50);
|
||||
return () => globalThis.clearTimeout(timerId);
|
||||
}, [accessChannels, active, initialName]);
|
||||
}, [active, initialName]);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName || !accessChannelId) return;
|
||||
if (!trimmedName) return;
|
||||
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await onCreate({
|
||||
accessChannelId,
|
||||
name: trimmedName,
|
||||
description: description.trim() || undefined,
|
||||
cloneUrl: cloneUrl.trim() || undefined,
|
||||
webUrl: webUrl.trim() || undefined,
|
||||
channelVisibility: settings.channelVisibility,
|
||||
projectVisibility: settings.projectVisibility,
|
||||
agents: settings.buildAgents(),
|
||||
});
|
||||
onCreated();
|
||||
} catch (error) {
|
||||
@@ -92,14 +77,12 @@ export function CreateProjectFormContent({
|
||||
className="max-w-lg"
|
||||
contentClassName="pt-3"
|
||||
data-testid="create-project-dialog"
|
||||
description="Projects group one or more repositories published to this workspace's relay."
|
||||
headerSubtitle="A project starts as a channel with a repository. People in the channel can talk, clone, and open tasks here."
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-end gap-3">
|
||||
<Button
|
||||
data-testid="create-project-submit"
|
||||
disabled={
|
||||
isCreating || name.trim().length === 0 || !accessChannelId
|
||||
}
|
||||
disabled={isCreating || name.trim().length === 0}
|
||||
form="create-project-form"
|
||||
type="submit"
|
||||
>
|
||||
@@ -167,44 +150,6 @@ export function CreateProjectFormContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="create-project-access-channel"
|
||||
>
|
||||
Repository access channel
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center px-3",
|
||||
CREATE_FIELD_SHELL_CLASS,
|
||||
)}
|
||||
>
|
||||
<select
|
||||
className={cn("h-8 w-full px-0 py-0", CREATE_FIELD_CONTROL_CLASS)}
|
||||
data-testid="create-project-access-channel"
|
||||
disabled={isCreating}
|
||||
id="create-project-access-channel"
|
||||
onChange={(event) => {
|
||||
setAccessChannelId(event.target.value);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
required
|
||||
value={accessChannelId}
|
||||
>
|
||||
<option value="">Select a channel</option>
|
||||
{accessChannels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{channel.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Members of this channel can access project repositories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
@@ -226,84 +171,14 @@ export function CreateProjectFormContent({
|
||||
setDescription(event.target.value);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
placeholder="What this project is about"
|
||||
placeholder="What this project should become"
|
||||
rows={2}
|
||||
value={description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="create-project-clone-url"
|
||||
>
|
||||
Initial repository clone URL
|
||||
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center px-3",
|
||||
CREATE_FIELD_SHELL_CLASS,
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
"h-8 px-0 py-0 leading-6",
|
||||
CREATE_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
data-testid="create-project-clone-url"
|
||||
disabled={isCreating}
|
||||
id="create-project-clone-url"
|
||||
onChange={(event) => {
|
||||
setCloneUrl(event.target.value);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
placeholder="https://relay.example.com/git/bee-garden-game.git"
|
||||
spellCheck={false}
|
||||
value={cloneUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="create-project-web-url"
|
||||
>
|
||||
Initial repository web URL
|
||||
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center px-3",
|
||||
CREATE_FIELD_SHELL_CLASS,
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
"h-8 px-0 py-0 leading-6",
|
||||
CREATE_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
data-testid="create-project-web-url"
|
||||
disabled={isCreating}
|
||||
id="create-project-web-url"
|
||||
onChange={(event) => {
|
||||
setWebUrl(event.target.value);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
placeholder="https://github.com/owner/repo"
|
||||
spellCheck={false}
|
||||
value={webUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CreateProjectFormSettings disabled={isCreating} {...settings} />
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
|
||||
149
desktop/src/features/projects/ui/CreateProjectFormSettings.tsx
Normal file
149
desktop/src/features/projects/ui/CreateProjectFormSettings.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { ChannelPermissionsSettings } from "@/features/channels/ui/ChannelPermissionsSettings";
|
||||
import type { CreateProjectFormSettingsState } from "@/features/projects/ui/useCreateProjectFormSettings";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
const NONE_AGENT_VALUE = "__none__";
|
||||
|
||||
const SETTINGS_ROW_CLASS =
|
||||
"flex min-h-12 items-center justify-between gap-4 rounded-xl border border-input bg-background px-3 py-3";
|
||||
|
||||
export function CreateProjectFormSettings({
|
||||
agentPersonaId,
|
||||
disabled,
|
||||
personas,
|
||||
projectVisibility,
|
||||
runtimesAvailable,
|
||||
setAgentPersonaId,
|
||||
setChannelVisibility,
|
||||
setProjectVisibility,
|
||||
channelVisibility,
|
||||
}: CreateProjectFormSettingsState & { disabled: boolean }) {
|
||||
const selectedPersona = personas.find(
|
||||
(persona) => persona.id === agentPersonaId,
|
||||
);
|
||||
const listingLabel = projectVisibility === "unlisted" ? "Unlisted" : "Listed";
|
||||
const agentLabel = selectedPersona?.displayName ?? "None";
|
||||
const agentDisabled = disabled || (!runtimesAvailable && personas.length > 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChannelPermissionsSettings
|
||||
disabled={disabled}
|
||||
onVisibilityChange={setChannelVisibility}
|
||||
testIdPrefix="create-project-channel"
|
||||
visibility={channelVisibility}
|
||||
/>
|
||||
|
||||
<div className={cn(SETTINGS_ROW_CLASS, disabled && "opacity-50")}>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Project list
|
||||
</span>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={`Project list: ${listingLabel}`}
|
||||
className="-mr-2.5 ml-auto h-9 w-fit justify-end px-2.5 text-right text-sm font-medium text-foreground hover:bg-muted/50"
|
||||
data-testid="create-project-listing"
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<span className="text-right">{listingLabel}</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground/70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
style={{
|
||||
minWidth: "var(--radix-dropdown-menu-trigger-width)",
|
||||
}}
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(value) =>
|
||||
setProjectVisibility(
|
||||
value === "unlisted" ? "unlisted" : "listed",
|
||||
)
|
||||
}
|
||||
value={projectVisibility}
|
||||
>
|
||||
<DropdownMenuRadioItem
|
||||
data-testid="create-project-listing-option-listed"
|
||||
value="listed"
|
||||
>
|
||||
Listed
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
data-testid="create-project-listing-option-unlisted"
|
||||
value="unlisted"
|
||||
>
|
||||
Unlisted
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className={cn(SETTINGS_ROW_CLASS, agentDisabled && "opacity-50")}>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Coding agent
|
||||
</span>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={`Coding agent: ${agentLabel}`}
|
||||
className="-mr-2.5 ml-auto h-9 min-w-0 max-w-[60%] justify-end px-2.5 text-right text-sm font-medium text-foreground hover:bg-muted/50"
|
||||
data-testid="create-project-agent"
|
||||
disabled={agentDisabled}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<span className="truncate text-right">{agentLabel}</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground/70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
style={{
|
||||
minWidth: "var(--radix-dropdown-menu-trigger-width)",
|
||||
}}
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(value) =>
|
||||
setAgentPersonaId(value === NONE_AGENT_VALUE ? "" : value)
|
||||
}
|
||||
value={agentPersonaId || NONE_AGENT_VALUE}
|
||||
>
|
||||
<DropdownMenuRadioItem
|
||||
data-testid="create-project-agent-option-none"
|
||||
value={NONE_AGENT_VALUE}
|
||||
>
|
||||
None
|
||||
</DropdownMenuRadioItem>
|
||||
{personas.map((persona) => (
|
||||
<DropdownMenuRadioItem
|
||||
data-testid={`create-project-agent-option-${persona.id}`}
|
||||
key={persona.id}
|
||||
value={persona.id}
|
||||
>
|
||||
{persona.displayName}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { CreateChannelManagedAgentInput } from "@/features/agents/channelAgents";
|
||||
import {
|
||||
useAvailableAcpRuntimes,
|
||||
usePersonasQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import { getActivePersonas } from "@/features/agents/lib/catalog";
|
||||
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
|
||||
import type { ProjectListingVisibility } from "@/features/projects/projectCreation";
|
||||
import type { ChannelVisibility } from "@/shared/api/types";
|
||||
|
||||
export function useCreateProjectFormSettings(active: boolean) {
|
||||
const personasQuery = usePersonasQuery({ enabled: active });
|
||||
const runtimesQuery = useAvailableAcpRuntimes({ enabled: active });
|
||||
const [channelVisibility, setChannelVisibility] =
|
||||
React.useState<ChannelVisibility>("open");
|
||||
const [projectVisibility, setProjectVisibility] =
|
||||
React.useState<ProjectListingVisibility>("listed");
|
||||
const [agentPersonaId, setAgentPersonaId] = React.useState("");
|
||||
|
||||
const personas = React.useMemo(
|
||||
() => getActivePersonas(personasQuery.data ?? []),
|
||||
[personasQuery.data],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) return;
|
||||
setChannelVisibility("open");
|
||||
setProjectVisibility("listed");
|
||||
setAgentPersonaId("");
|
||||
}, [active]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
agentPersonaId &&
|
||||
!personas.some((persona) => persona.id === agentPersonaId)
|
||||
) {
|
||||
setAgentPersonaId("");
|
||||
}
|
||||
}, [agentPersonaId, personas]);
|
||||
|
||||
const buildAgents =
|
||||
React.useCallback((): CreateChannelManagedAgentInput[] => {
|
||||
if (!agentPersonaId) return [];
|
||||
const persona = personas.find((entry) => entry.id === agentPersonaId);
|
||||
if (!persona) {
|
||||
throw new Error("Choose an agent that still exists.");
|
||||
}
|
||||
const defaultRuntime = runtimesQuery.data[0] ?? null;
|
||||
const resolved = resolvePersonaRuntime(
|
||||
persona.runtime,
|
||||
runtimesQuery.data,
|
||||
defaultRuntime,
|
||||
false,
|
||||
);
|
||||
if (!resolved.runtime) {
|
||||
throw new Error(
|
||||
resolved.warnings[0] ??
|
||||
"No agent runtimes are available. Install a runtime to add an agent.",
|
||||
);
|
||||
}
|
||||
return [
|
||||
{
|
||||
runtime: resolved.runtime,
|
||||
name: persona.displayName,
|
||||
personaId: persona.id,
|
||||
harnessOverride: false,
|
||||
systemPrompt: persona.systemPrompt,
|
||||
avatarUrl: persona.avatarUrl ?? undefined,
|
||||
model: persona.model ?? undefined,
|
||||
role: "bot",
|
||||
backend: { type: "local" },
|
||||
},
|
||||
];
|
||||
}, [agentPersonaId, personas, runtimesQuery.data]);
|
||||
|
||||
return {
|
||||
agentPersonaId,
|
||||
buildAgents,
|
||||
channelVisibility,
|
||||
personas,
|
||||
projectVisibility,
|
||||
runtimesAvailable: runtimesQuery.data.length > 0,
|
||||
setAgentPersonaId,
|
||||
setChannelVisibility,
|
||||
setProjectVisibility,
|
||||
};
|
||||
}
|
||||
|
||||
export type CreateProjectFormSettingsState = ReturnType<
|
||||
typeof useCreateProjectFormSettings
|
||||
>;
|
||||
@@ -2,135 +2,34 @@ import * as React from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
fetchProjects,
|
||||
type Project,
|
||||
projectsQueryKey,
|
||||
} from "@/features/projects/hooks";
|
||||
channelsQueryKey,
|
||||
upsertCachedChannel,
|
||||
} from "@/features/channels/hooks";
|
||||
import { type Project, projectsQueryKey } from "@/features/projects/hooks";
|
||||
import {
|
||||
buildInitialProjectEventTemplates,
|
||||
isUnsupportedProjectKindError,
|
||||
} from "@/features/projects/projectCreation";
|
||||
createProject,
|
||||
type CreateProjectInput,
|
||||
type CreateProjectResult,
|
||||
type CreateProjectResumeState,
|
||||
} from "@/features/projects/createProject";
|
||||
import { addProjectToSidebar } from "@/features/projects/lib/projectSidebarMembership";
|
||||
import { buildProjectReadModels } from "@/features/projects/projectModels";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl";
|
||||
import { signRelayEvent } from "@/shared/api/tauri";
|
||||
import { getIdentity } from "@/shared/api/tauriIdentity";
|
||||
|
||||
export type CreateProjectInput = {
|
||||
accessChannelId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
cloneUrl?: string;
|
||||
webUrl?: string;
|
||||
};
|
||||
export type { CreateProjectInput, CreateProjectResult };
|
||||
|
||||
export type CreateProjectResult = {
|
||||
project: Project;
|
||||
compatibilityWarning?: string;
|
||||
};
|
||||
|
||||
/** Publishes a project announcement and its initial NIP-34 repository. */
|
||||
async function createProject(
|
||||
input: CreateProjectInput,
|
||||
resumableProjectIds: Set<string>,
|
||||
): Promise<CreateProjectResult> {
|
||||
const identity = await getIdentity();
|
||||
const templates = buildInitialProjectEventTemplates({
|
||||
...input,
|
||||
ownerPubkey: identity.pubkey,
|
||||
});
|
||||
const existing = await fetchProjects();
|
||||
const ownerPubkey = identity.pubkey.toLowerCase();
|
||||
const existingProject = existing.find(
|
||||
(project) =>
|
||||
project.owner.toLowerCase() === ownerPubkey &&
|
||||
project.dtag === templates.dtag,
|
||||
);
|
||||
const projectId = `${ownerPubkey}:${templates.dtag}`;
|
||||
const canResume = resumableProjectIds.has(projectId);
|
||||
if (existingProject && !canResume) {
|
||||
throw new Error(`You already have a project named "${templates.dtag}".`);
|
||||
}
|
||||
if (existingProject && !existingProject.legacy) {
|
||||
if (
|
||||
existingProject.repositories.some(
|
||||
(repository) => repository.repoAddress === templates.repositoryAddress,
|
||||
)
|
||||
) {
|
||||
resumableProjectIds.delete(projectId);
|
||||
return { project: existingProject };
|
||||
}
|
||||
throw new Error(`You already have a project named "${templates.dtag}".`);
|
||||
}
|
||||
|
||||
resumableProjectIds.add(projectId);
|
||||
const projectEvent = await signRelayEvent(templates.project);
|
||||
|
||||
let repositoryEvent = null;
|
||||
if (!existingProject) {
|
||||
repositoryEvent = await signRelayEvent(templates.repository);
|
||||
await relayClient.publishEvent(
|
||||
repositoryEvent,
|
||||
"Timed out creating the initial repository.",
|
||||
"Failed to create the initial repository.",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await relayClient.publishEvent(
|
||||
projectEvent,
|
||||
"Timed out creating project.",
|
||||
"Failed to create project.",
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isUnsupportedProjectKindError(error)) throw error;
|
||||
|
||||
const [legacyProject] = existingProject?.legacy
|
||||
? [existingProject]
|
||||
: buildProjectReadModels({
|
||||
projectEvents: [],
|
||||
repositoryEvents: repositoryEvent ? [repositoryEvent] : [],
|
||||
relayOrigin: getCachedRelayOrigin(),
|
||||
});
|
||||
if (!legacyProject) throw error;
|
||||
|
||||
resumableProjectIds.delete(projectId);
|
||||
return {
|
||||
project: legacyProject,
|
||||
compatibilityWarning:
|
||||
"The repository was created, but this relay does not support multi-repository projects yet. It will appear as a standalone project.",
|
||||
};
|
||||
}
|
||||
|
||||
const [project] = repositoryEvent
|
||||
? buildProjectReadModels({
|
||||
projectEvents: [projectEvent],
|
||||
repositoryEvents: [repositoryEvent],
|
||||
relayOrigin: getCachedRelayOrigin(),
|
||||
})
|
||||
: (await fetchProjects()).filter(
|
||||
(candidate) =>
|
||||
candidate.owner.toLowerCase() === ownerPubkey &&
|
||||
candidate.dtag === templates.dtag &&
|
||||
!candidate.legacy,
|
||||
);
|
||||
if (!project) {
|
||||
throw new Error("The project was created but could not be read.");
|
||||
}
|
||||
resumableProjectIds.delete(projectId);
|
||||
return { project };
|
||||
}
|
||||
|
||||
/** Mutation that creates a project and inserts it into the projects cache. */
|
||||
/** Mutation that creates a project home and inserts it into the caches. */
|
||||
export function useCreateProjectMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const resumableProjectIdsRef = React.useRef(new Set<string>());
|
||||
const resumeRef = React.useRef<CreateProjectResumeState>({
|
||||
channels: new Map(),
|
||||
projectIds: new Set(),
|
||||
});
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateProjectInput) =>
|
||||
createProject(input, resumableProjectIdsRef.current),
|
||||
onSuccess: ({ project }) => {
|
||||
createProject(input, resumeRef.current),
|
||||
onSuccess: ({ channel, project }) => {
|
||||
addProjectToSidebar(
|
||||
project.projectAddress,
|
||||
getCachedRelayOrigin(),
|
||||
@@ -148,6 +47,17 @@ export function useCreateProjectMutation() {
|
||||
),
|
||||
),
|
||||
]);
|
||||
if (channel) {
|
||||
queryClient.setQueryData(
|
||||
channelsQueryKey,
|
||||
(current: Channel[] | undefined) =>
|
||||
upsertCachedChannel(current, channel),
|
||||
);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: channelsQueryKey,
|
||||
refetchType: "none",
|
||||
});
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user