Add related channel management to project home

Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-09-01 16:52:54 -04:00
parent 08979b181d
commit d86d5ddd3f
21 changed files with 1610 additions and 110 deletions

View File

@@ -128,6 +128,7 @@ export default defineConfig({
"**/projects-v3-screenshots.spec.ts",
"**/project-issue-comments.spec.ts",
"**/project-pr-review.spec.ts",
"**/project-related-channels.spec.ts",
"**/persona-model-combobox-screenshots.spec.ts",
"**/drafts-screenshots.spec.ts",
"**/drafts-all-fix-screenshots.spec.ts",

View File

@@ -0,0 +1,120 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
canManageProjectRelatedChannels,
listLinkableProjectChannels,
} from "./projectRelatedChannelAccess.ts";
const OWNER = "a".repeat(64);
const ADMIN = "b".repeat(64);
const HOME = "11111111-1111-4111-8111-111111111111";
const RELATED = "22222222-2222-4222-8222-222222222222";
const REPOSITORY = "33333333-3333-4333-8333-333333333333";
const AVAILABLE = "44444444-4444-4444-8444-444444444444";
function project(overrides = {}) {
return {
legacy: false,
owner: OWNER,
projectChannelId: HOME,
relatedChannelIds: [RELATED],
repositories: [{ channelId: REPOSITORY }],
...overrides,
};
}
function member(pubkey, role) {
return { pubkey, role };
}
function channel(id, overrides = {}) {
return {
id,
name: id,
archivedAt: null,
channelType: "stream",
isMember: true,
...overrides,
};
}
test("project owner and active home-channel admins can manage related channels", () => {
assert.equal(
canManageProjectRelatedChannels({
homeChannelMembers: [],
homeChannelActive: true,
identityPubkey: OWNER.toUpperCase(),
project: project(),
}),
true,
);
for (const role of ["owner", "admin"]) {
assert.equal(
canManageProjectRelatedChannels({
homeChannelMembers: [member(ADMIN, role)],
homeChannelActive: true,
identityPubkey: ADMIN,
project: project(),
}),
true,
);
}
});
test("ordinary members, legacy projects, and non-owners without a home channel cannot manage", () => {
for (const role of ["member", "guest", "bot"]) {
assert.equal(
canManageProjectRelatedChannels({
homeChannelMembers: [member(ADMIN, role)],
homeChannelActive: true,
identityPubkey: ADMIN,
project: project(),
}),
false,
);
}
assert.equal(
canManageProjectRelatedChannels({
homeChannelMembers: [member(ADMIN, "admin")],
homeChannelActive: true,
identityPubkey: ADMIN,
project: project({ legacy: true }),
}),
false,
);
assert.equal(
canManageProjectRelatedChannels({
homeChannelMembers: [member(ADMIN, "admin")],
homeChannelActive: true,
identityPubkey: ADMIN,
project: project({ projectChannelId: null }),
}),
false,
);
assert.equal(
canManageProjectRelatedChannels({
homeChannelActive: false,
homeChannelMembers: [member(ADMIN, "admin")],
identityPubkey: ADMIN,
project: project(),
}),
false,
);
});
test("link candidates exclude bound, unavailable, archived, and DM channels", () => {
const candidates = listLinkableProjectChannels(project(), [
channel(HOME),
channel(RELATED),
channel(REPOSITORY),
channel(AVAILABLE, { name: "Available" }),
channel("not-member", { isMember: false }),
channel("archived", { archivedAt: "2026-09-01T00:00:00Z" }),
channel("dm", { channelType: "dm" }),
]);
assert.deepEqual(
candidates.map((candidate) => candidate.id),
[AVAILABLE],
);
});

View File

@@ -0,0 +1,41 @@
import type { Project } from "@/features/projects/projectModels";
import { listProjectBoundChannels } from "@/features/projects/lib/projectRelatedChannels";
import type { Channel, ChannelMember } from "@/shared/api/types";
export function canManageProjectRelatedChannels(input: {
homeChannelActive: boolean;
homeChannelMembers: readonly ChannelMember[] | undefined;
identityPubkey: string | undefined;
project: Pick<Project, "legacy" | "owner" | "projectChannelId">;
}): boolean {
const identity = input.identityPubkey?.toLowerCase();
if (!identity || input.project.legacy) return false;
if (identity === input.project.owner.toLowerCase()) return true;
if (!input.project.projectChannelId || !input.homeChannelActive) return false;
const role = input.homeChannelMembers?.find(
(member) => member.pubkey.toLowerCase() === identity,
)?.role;
return role === "owner" || role === "admin";
}
export function listLinkableProjectChannels(
project: Pick<
Project,
"projectChannelId" | "relatedChannelIds" | "repositories"
>,
channels: readonly Channel[],
): Channel[] {
const boundIds = new Set(
listProjectBoundChannels(project).map((binding) => binding.channelId),
);
return channels
.filter(
(channel) =>
channel.isMember &&
channel.channelType !== "dm" &&
channel.archivedAt === null &&
!boundIds.has(channel.id),
)
.sort((left, right) => left.name.localeCompare(right.name));
}

View File

@@ -6,6 +6,7 @@ import {
collectProjectRelatedChannelRows,
listProjectBoundChannels,
listProjectChildChannels,
isExplicitProjectRelatedChannel,
projectRelatedChannelRowKey,
uniqueProjectRelatedChannelCount,
} from "./projectRelatedChannels.ts";
@@ -107,6 +108,12 @@ test("collects one row per repository channel binding", () => {
);
});
test("identifies only explicit related-channel membership", () => {
const project = makeProject({ relatedChannelIds: [CHANNEL_A] });
assert.equal(isExplicitProjectRelatedChannel(project, CHANNEL_A), true);
assert.equal(isExplicitProjectRelatedChannel(project, CHANNEL_B), false);
});
test("collapses repositories sharing one project channel", () => {
const rows = collectProjectRelatedChannelRows([
makeProject({

View File

@@ -194,3 +194,13 @@ export function listProjectChildChannels(
(channel) => channel.role !== "home",
);
}
/** Whether this channel is an explicit Project membership, not a derived repository binding. */
export function isExplicitProjectRelatedChannel(
project: Pick<ProjectRelatedChannelSource, "relatedChannelIds">,
channelId: string,
): boolean {
return (project.relatedChannelIds ?? []).some(
(candidate) => candidate.trim() === channelId,
);
}

View File

@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
import {
enumerateProjectEvents,
@@ -91,6 +92,71 @@ test("buildProjectHomeFromFetcher scopes startup lookup to the active channel",
});
});
test("buildProjectsFromFetcher consumes relay-signed effective Project tags", async () => {
const relaySecret = new Uint8Array(32).fill(11);
const relayPubkey = getPublicKey(relaySecret);
const owner = "a".repeat(64);
const identityId = "b".repeat(64);
const coordinate = `30621:${owner}:relay`;
const related = "22222222-2222-4222-8222-222222222222";
const projectEvent = {
id: identityId,
sig: "owner-signature",
kind: 30621,
pubkey: owner,
created_at: 200,
content: "",
tags: [
["d", "relay"],
["name", "Owner name"],
],
};
const stateEvent = finalizeEvent(
{
kind: 30623,
created_at: 201,
content: JSON.stringify({
v: 1,
deleted: false,
project_tags: [
["d", "relay"],
["name", "Effective name"],
["buzz-related-channel", related],
],
}),
tags: [
["d", "c".repeat(64)],
["a", coordinate],
["rev", "2"],
["e", identityId, "", "identity"],
["e", "d".repeat(64), "", "change"],
],
},
relaySecret,
);
const calls = [];
const projects = await buildProjectsFromFetcher(
async (kinds, extraFilter) => {
calls.push({ kinds, extraFilter });
if (kinds.includes(30621)) return [projectEvent];
if (kinds.includes(30623)) return [stateEvent];
return [];
},
{ relayPubkey },
);
assert.equal(projects[0].name, "Effective name");
assert.deepEqual(projects[0].relatedChannelIds, [related]);
assert.ok(
calls.some(
({ kinds, extraFilter }) =>
kinds.includes(30623) &&
extraFilter.authors[0] === relayPubkey &&
extraFilter["#a"][0] === coordinate,
),
);
});
test("enumerateProjectEvents drains a tied boundary second before advancing", async () => {
const events = [
relayEvent("a", 1_000),

View File

@@ -3,6 +3,7 @@ import type { RelayEvent } from "@/shared/api/types";
import {
KIND_DELETION,
KIND_PROJECT_ANNOUNCEMENT,
KIND_PROJECT_STATE,
KIND_REPO_ANNOUNCEMENT,
} from "@/shared/constants/kinds";
import { absorbStandaloneProjectRepositories } from "./lib/projectCollection";
@@ -19,6 +20,7 @@ const TOMBSTONE_COORDINATE_CHUNK_SIZE = 100;
export type ProjectEventExtraFilter = {
"#a"?: string[];
"#buzz-channel"?: string[];
authors?: string[];
};
type ProjectEventFilter = ProjectEventExtraFilter & {
@@ -161,6 +163,37 @@ async function fetchScopedDeletionEvents(
return pages.flat();
}
async function fetchScopedProjectStateEvents(
fetchExhaustively: FetchProjectEventsExhaustively,
projectEvents: RelayEvent[],
relayPubkey: string,
): Promise<RelayEvent[]> {
const coordinates = [
...new Set(
projectEvents.flatMap((event) => {
const coordinate = eventCoordinate(event);
return coordinate ? [coordinate] : [];
}),
),
];
if (coordinates.length === 0) return [];
const pages: Promise<RelayEvent[]>[] = [];
for (
let index = 0;
index < coordinates.length;
index += TOMBSTONE_COORDINATE_CHUNK_SIZE
) {
pages.push(
fetchExhaustively([KIND_PROJECT_STATE], {
authors: [relayPubkey],
"#a": coordinates.slice(index, index + TOMBSTONE_COORDINATE_CHUNK_SIZE),
}),
);
}
return (await Promise.all(pages)).flat();
}
/**
* Core fetch-and-build logic for `fetchProjects`, extracted for testability.
*
@@ -177,12 +210,20 @@ export async function buildProjectsFromFetcher(
relayOrigin?: string | null;
hiddenAddresses?: ReadonlySet<string>;
viewerPubkey?: string | null;
relayPubkey?: string | null;
} = {},
): Promise<Project[]> {
const [projectEvents, repositoryEvents] = await Promise.all([
fetchExhaustively([KIND_PROJECT_ANNOUNCEMENT]),
fetchExhaustively([KIND_REPO_ANNOUNCEMENT]),
]);
const projectStateEvents = options.relayPubkey
? await fetchScopedProjectStateEvents(
fetchExhaustively,
projectEvents,
options.relayPubkey,
)
: [];
// Tombstones are fetched second (not in parallel) because the `#a` scoping
// needs the announcement coordinates; both announcement kinds are small,
@@ -207,6 +248,8 @@ export async function buildProjectsFromFetcher(
return absorbStandaloneProjectRepositories(
buildProjectReadModels({
projectEvents,
projectStateEvents,
relayPubkey: options.relayPubkey,
repositoryEvents,
deletionEvents: tombstoneResult.events,
relayOrigin: options.relayOrigin ?? null,
@@ -227,13 +270,14 @@ export async function buildProjectHomeFromFetcher(
relayOrigin?: string | null;
hiddenAddresses?: ReadonlySet<string>;
viewerPubkey?: string | null;
relayPubkey?: string | null;
} = {},
): Promise<Project | null> {
const projects = await buildProjectsFromFetcher(
(kinds, extraFilter) =>
fetchExhaustively(
kinds,
kinds.includes(KIND_DELETION)
kinds.includes(KIND_DELETION) || kinds.includes(KIND_PROJECT_STATE)
? extraFilter
: { ...extraFilter, "#buzz-channel": [channelId] },
),

View File

@@ -1,5 +1,6 @@
import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { getRelaySelf } from "@/features/moderation/lib/relaySelf";
import {
buildProjectHomeFromFetcher,
buildProjectsFromFetcher,
@@ -36,9 +37,12 @@ export async function fetchProjects(
// Delegates to `buildProjectsFromFetcher` in `projectEnumeration.ts`, which
// 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 [viewerPubkey, relayPubkey] = await Promise.all([
getIdentity()
.then((identity) => identity.pubkey)
.catch(() => undefined),
getRelaySelf().catch(() => null),
]);
const fetcher: FetchProjectEventsExhaustively =
fetchExhaustively ??
((kinds, extraFilter) =>
@@ -47,6 +51,7 @@ export async function fetchProjects(
relayOrigin: getCachedRelayOrigin(),
hiddenAddresses: new Set(readHiddenProjectCards()),
viewerPubkey,
relayPubkey,
});
return projects.map((project) =>
markProjectDataAuthoritative(project, "relay"),
@@ -58,9 +63,12 @@ export async function fetchProjectHomeForChannel(
channelId: string,
signal?: AbortSignal,
): Promise<Project | null> {
const viewerPubkey = await getIdentity()
.then((identity) => identity.pubkey)
.catch(() => undefined);
const [viewerPubkey, relayPubkey] = await Promise.all([
getIdentity()
.then((identity) => identity.pubkey)
.catch(() => undefined),
getRelaySelf().catch(() => null),
]);
const project = await buildProjectHomeFromFetcher(
(kinds, extraFilter) =>
fetchProjectEventsExhaustively(kinds, extraFilter, undefined, signal),
@@ -69,6 +77,7 @@ export async function fetchProjectHomeForChannel(
relayOrigin: getCachedRelayOrigin(),
hiddenAddresses: new Set(readHiddenProjectCards()),
viewerPubkey,
relayPubkey,
},
);
return project ? markProjectDataAuthoritative(project, "relay") : null;

View File

@@ -4,6 +4,7 @@ import {
KIND_REPO_ANNOUNCEMENT,
} from "@/shared/constants/kinds";
import { effectiveCloneUrls } from "./lib/projectCloneUrl";
import { parseProjectState, type ProjectState } from "./projectState";
export type Repository = {
id: string;
@@ -56,6 +57,10 @@ export function isExplicitProject(project: Project): boolean {
type BuildProjectReadModelsInput = {
projectEvents: RelayEvent[];
/** Relay-authored NIP-PC projections for the requested Project coordinates. */
projectStateEvents?: RelayEvent[];
/** Trusted NIP-11 relay signing key. Without it, owner identity is used. */
relayPubkey?: string | null;
repositoryEvents: RelayEvent[];
/** NIP-09 kind:5 deletion events relevant to projects and repositories. */
deletionEvents?: RelayEvent[];
@@ -454,6 +459,8 @@ function projectIsListingEligible(
export function buildProjectReadModels({
projectEvents,
projectStateEvents = [],
relayPubkey,
repositoryEvents,
deletionEvents = [],
relayOrigin,
@@ -490,20 +497,65 @@ export function buildProjectReadModels({
]),
);
const explicitProjects = deduplicateAddressableEvents(projectEvents)
.filter((event) => !isDeleted(event))
.flatMap((event) => {
const project = eventToExplicitProject(
event,
repositoriesByAddress,
visibleRepositoriesByAddress,
);
return project &&
projectIsListingEligible(project, viewerPubkey) &&
!hiddenAddresses.has(project.projectAddress)
? [project]
: [];
});
const currentProjectEvents = deduplicateAddressableEvents(
projectEvents,
).filter((event) => !isDeleted(event));
const projectStateByCoordinate = new Map<string, ProjectState>();
if (relayPubkey) {
const stateEventsByCoordinate = new Map<string, RelayEvent[]>();
for (const event of projectStateEvents) {
const coordinates = event.tags.filter((tag) => tag[0] === "a");
if (coordinates.length !== 1 || coordinates[0].length !== 2) continue;
const coordinate = coordinates[0][1];
const events = stateEventsByCoordinate.get(coordinate) ?? [];
events.push(event);
stateEventsByCoordinate.set(coordinate, events);
}
for (const identityEvent of currentProjectEvents) {
const dtag = getTag(identityEvent, "d");
if (!dtag) continue;
const coordinate = `${KIND_PROJECT_ANNOUNCEMENT}:${identityEvent.pubkey.toLowerCase()}:${dtag}`;
const candidates = (
stateEventsByCoordinate.get(coordinate) ?? []
).flatMap((event) => {
try {
const state = parseProjectState(event, relayPubkey, coordinate);
return state.identityEventId === identityEvent.id ? [state] : [];
} catch {
return [];
}
});
// An addressable query should have one winner. Ambiguous valid results
// are not safe to guess between, so retain the portable owner identity.
if (candidates.length === 1) {
projectStateByCoordinate.set(coordinate, candidates[0]);
}
}
}
const explicitProjects = currentProjectEvents.flatMap((event) => {
const dtag = getTag(event, "d");
const coordinate = dtag
? `${KIND_PROJECT_ANNOUNCEMENT}:${event.pubkey.toLowerCase()}:${dtag}`
: null;
const state = coordinate
? projectStateByCoordinate.get(coordinate)
: undefined;
if (state?.deleted) return [];
const effectiveEvent = state
? { ...event, tags: state.projectTags.map((tag) => [...tag]) }
: event;
const project = eventToExplicitProject(
effectiveEvent,
repositoriesByAddress,
visibleRepositoriesByAddress,
);
return project &&
projectIsListingEligible(project, viewerPubkey) &&
!hiddenAddresses.has(project.projectAddress)
? [project]
: [];
});
const claimedRepositories = new Set(
explicitProjects.flatMap((project) =>
project.repositoryAddresses.filter((address) => {

View File

@@ -0,0 +1,173 @@
import assert from "node:assert/strict";
import test from "node:test";
import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
import {
buildProjectRelatedChannelChangeTemplate,
changeProjectRelatedChannels,
fetchProjectState,
parseProjectState,
} from "./projectState.ts";
const RELAY_SECRET = new Uint8Array(32).fill(7);
const RELAY_PUBKEY = getPublicKey(RELAY_SECRET);
const OWNER = "a".repeat(64);
const IDENTITY_ID = "b".repeat(64);
const COORDINATE = `30621:${OWNER}:buzz`;
const CHANNEL_A = "11111111-1111-4111-8111-111111111111";
const CHANNEL_B = "22222222-2222-4222-8222-222222222222";
function stateEvent({
revision = "1",
identityId = IDENTITY_ID,
content,
} = {}) {
return finalizeEvent(
{
kind: 30623,
created_at: 100,
content:
content ??
JSON.stringify({
v: 1,
deleted: false,
project_tags: [
["d", "buzz"],
["name", "Buzz"],
["buzz-related-channel", CHANNEL_A],
],
}),
tags: [
["d", "c".repeat(64)],
["a", COORDINATE],
["rev", revision],
["e", identityId, "", "identity"],
["e", "d".repeat(64), "", "change"],
],
},
RELAY_SECRET,
);
}
test("parseProjectState accepts a signed exact-coordinate strict v1 projection", () => {
const state = parseProjectState(stateEvent(), RELAY_PUBKEY, COORDINATE);
assert.equal(state.revision, "1");
assert.equal(state.identityEventId, IDENTITY_ID);
assert.deepEqual(state.projectTags.at(-1), [
"buzz-related-channel",
CHANNEL_A,
]);
});
test("parseProjectState rejects wrong authors, signatures, revisions, coordinates, and unknown content fields", () => {
assert.throws(
() => parseProjectState(stateEvent(), "e".repeat(64), COORDINATE),
/signed by this relay/,
);
const badSignature = JSON.parse(JSON.stringify(stateEvent()));
badSignature.sig = "0".repeat(128);
assert.throws(
() => parseProjectState(badSignature, RELAY_PUBKEY, COORDINATE),
/signed by this relay/,
);
assert.throws(
() =>
parseProjectState(
stateEvent({ revision: "01" }),
RELAY_PUBKEY,
COORDINATE,
),
/revision is not canonical/,
);
assert.throws(
() => parseProjectState(stateEvent(), RELAY_PUBKEY, `30621:${OWNER}:other`),
/requested coordinate/,
);
assert.throws(
() =>
parseProjectState(
stateEvent({
content: JSON.stringify({
v: 1,
deleted: false,
project_tags: [],
extra: true,
}),
}),
RELAY_PUBKEY,
COORDINATE,
),
/strict version 1/,
);
});
test("buildProjectRelatedChannelChangeTemplate emits the exact sorted kind:47010 command", () => {
assert.deepEqual(
buildProjectRelatedChannelChangeTemplate(
COORDINATE,
{ add: [CHANNEL_B, CHANNEL_A], remove: [] },
"7",
),
{
kind: 47010,
tags: [
["a", COORDINATE],
["expected-revision", "7"],
],
content: JSON.stringify({
v: 1,
patch: {
related_channels: { add: [CHANNEL_A, CHANNEL_B], remove: [] },
},
}),
},
);
});
test("fetchProjectState distinguishes absence from a present untrusted projection", async () => {
const deps = {
getRelayPubkey: async () => RELAY_PUBKEY,
fetchEvents: async () => [],
};
assert.equal(await fetchProjectState(COORDINATE, deps), null);
await assert.rejects(
fetchProjectState(COORDINATE, {
...deps,
fetchEvents: async () => [{ ...stateEvent(), content: "{}" }],
}),
/untrusted or unsupported/,
);
});
test("changeProjectRelatedChannels refetches and retries one revision conflict", async () => {
const states = [stateEvent({ revision: "4" }), stateEvent({ revision: "5" })];
const signedTemplates = [];
let fetchCount = 0;
let publishCount = 0;
await changeProjectRelatedChannels(
{ projectAddress: COORDINATE },
{ add: [CHANNEL_B], remove: [] },
{
getRelayPubkey: async () => RELAY_PUBKEY,
fetchEvents: async () => [states[fetchCount++]],
signEvent: async (template) => {
signedTemplates.push(template);
return stateEvent();
},
publishEvent: async () => {
publishCount += 1;
if (publishCount === 1) {
throw new Error("conflict: Project revision is 5");
}
},
},
);
assert.equal(fetchCount, 2);
assert.equal(publishCount, 2);
assert.deepEqual(
signedTemplates.map((template) => template.tags[1][1]),
["4", "5"],
);
});

View File

@@ -0,0 +1,355 @@
import { verifyEvent } from "nostr-tools/pure";
import { getRelaySelf } from "@/features/moderation/lib/relaySelf";
import { relayClient } from "@/shared/api/relayClient";
import type { RelayEvent } from "@/shared/api/types";
import { signRelayEvent } from "@/shared/api/tauri";
import {
KIND_PROJECT_ANNOUNCEMENT,
KIND_PROJECT_CHANGE,
KIND_PROJECT_STATE,
} from "@/shared/constants/kinds";
import type { Project } from "./projectModels";
const MAX_PROJECT_D_TAG_BYTES = 1_024;
const MAX_PATCH_CHANNELS = 64;
const MAX_REVISION = 9_223_372_036_854_775_807n;
const CANONICAL_UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const LOWER_HEX_64 = /^[0-9a-f]{64}$/;
export type ProjectState = {
deleted: boolean;
event: RelayEvent;
identityEventId: string;
projectTags: string[][];
revision: string;
};
export type ProjectRelatedChannelPatch = {
add: string[];
remove: string[];
};
export type ProjectRelatedChannelChangeTemplate = {
content: string;
kind: number;
tags: string[][];
};
type ProjectStateFilter = {
"#a": string[];
authors: string[];
kinds: number[];
limit: number;
};
type ProjectStateDeps = {
fetchEvents: (filter: ProjectStateFilter) => Promise<RelayEvent[]>;
getRelayPubkey: () => Promise<string | null>;
};
type ChangeProjectRelatedChannelsDeps = ProjectStateDeps & {
publishEvent: (
event: RelayEvent,
timeoutMessage: string,
failureMessage: string,
) => Promise<unknown>;
signEvent: (
input: ProjectRelatedChannelChangeTemplate,
) => Promise<RelayEvent>;
};
function parseProjectCoordinate(coordinate: string): { dtag: string } {
const first = coordinate.indexOf(":");
const second = coordinate.indexOf(":", first + 1);
const kind = coordinate.slice(0, first);
const owner = coordinate.slice(first + 1, second);
const dtag = coordinate.slice(second + 1);
if (
kind !== String(KIND_PROJECT_ANNOUNCEMENT) ||
second < 0 ||
!LOWER_HEX_64.test(owner) ||
dtag.length === 0 ||
new TextEncoder().encode(dtag).byteLength > MAX_PROJECT_D_TAG_BYTES
) {
throw new Error("Invalid canonical Project coordinate.");
}
return { dtag };
}
function exactObjectKeys(
value: Record<string, unknown>,
expected: readonly string[],
): boolean {
const keys = Object.keys(value).sort();
return (
keys.length === expected.length &&
keys.every((key, index) => key === [...expected].sort()[index])
);
}
function parseStrictContent(content: string): {
deleted: boolean;
projectTags: string[][];
} {
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
throw new Error("Project State content is not valid JSON.");
}
if (
!parsed ||
typeof parsed !== "object" ||
Array.isArray(parsed) ||
!exactObjectKeys(parsed as Record<string, unknown>, [
"deleted",
"project_tags",
"v",
])
) {
throw new Error("Project State content is not strict version 1.");
}
const body = parsed as Record<string, unknown>;
if (
body.v !== 1 ||
typeof body.deleted !== "boolean" ||
!Array.isArray(body.project_tags) ||
!body.project_tags.every(
(tag) =>
Array.isArray(tag) &&
tag.length > 0 &&
tag.every((value) => typeof value === "string"),
)
) {
throw new Error("Project State content is not strict version 1.");
}
const projectTags = body.project_tags as string[][];
if (body.deleted && projectTags.length !== 0) {
throw new Error("Deleted Project State must not contain Project tags.");
}
return { deleted: body.deleted, projectTags };
}
function singleExactTag(event: RelayEvent, name: string): string[] | undefined {
const tags = event.tags.filter((tag) => tag[0] === name);
return tags.length === 1 ? tags[0] : undefined;
}
function hasValidSignature(event: RelayEvent): boolean {
try {
return verifyEvent(event);
} catch {
return false;
}
}
/** Validate one relay-authored NIP-PC projection for an exact Project coordinate. */
export function parseProjectState(
event: RelayEvent,
relayPubkey: string,
coordinate: string,
): ProjectState {
const { dtag } = parseProjectCoordinate(coordinate);
const normalizedRelay = relayPubkey.trim().toLowerCase();
if (!LOWER_HEX_64.test(normalizedRelay)) {
throw new Error("The relay did not advertise a valid signing identity.");
}
if (
event.kind !== KIND_PROJECT_STATE ||
event.pubkey !== normalizedRelay ||
!hasValidSignature(event)
) {
throw new Error("Project State is not validly signed by this relay.");
}
const coordinateTag = singleExactTag(event, "a");
if (coordinateTag?.length !== 2 || coordinateTag[1] !== coordinate) {
throw new Error("Project State does not match the requested coordinate.");
}
const revisionTag = singleExactTag(event, "rev");
const revision = revisionTag?.[1] ?? "";
if (
revisionTag?.length !== 2 ||
!/^[1-9][0-9]*$/.test(revision) ||
BigInt(revision) > MAX_REVISION
) {
throw new Error("Project State revision is not canonical.");
}
const identityTags = event.tags.filter(
(tag) => tag[0] === "e" && tag[3] === "identity",
);
if (
identityTags.length !== 1 ||
identityTags[0].length !== 4 ||
!LOWER_HEX_64.test(identityTags[0][1] ?? "") ||
identityTags[0][2] !== ""
) {
throw new Error("Project State identity marker is malformed.");
}
const { deleted, projectTags } = parseStrictContent(event.content);
const projectDTags = projectTags.filter((tag) => tag[0] === "d");
if (
!deleted &&
(projectDTags.length !== 1 ||
projectDTags[0].length !== 2 ||
projectDTags[0][1] !== dtag)
) {
throw new Error(
"Project State tags do not match the requested coordinate.",
);
}
return {
deleted,
event,
identityEventId: identityTags[0][1],
projectTags,
revision,
};
}
function projectCoordinate(
project: string | Pick<Project, "projectAddress">,
): string {
return typeof project === "string" ? project : project.projectAddress;
}
/** Fetch and validate the current relay-authored state at mutation time. */
export async function fetchProjectState(
project: string | Pick<Project, "projectAddress">,
deps?: Partial<ProjectStateDeps>,
): Promise<ProjectState | null> {
const coordinate = projectCoordinate(project);
parseProjectCoordinate(coordinate);
const getRelayPubkey = deps?.getRelayPubkey ?? getRelaySelf;
const fetchEvents =
deps?.fetchEvents ?? relayClient.fetchEvents.bind(relayClient);
const relayPubkey = await getRelayPubkey();
if (!relayPubkey) {
throw new Error("Could not verify the relay identity for Project State.");
}
const normalizedRelay = relayPubkey.toLowerCase();
const events = await fetchEvents({
kinds: [KIND_PROJECT_STATE],
authors: [normalizedRelay],
"#a": [coordinate],
limit: 2,
});
if (events.length > 1) {
throw new Error("The relay returned ambiguous Project State.");
}
if (events.length === 0) return null;
try {
return parseProjectState(events[0], normalizedRelay, coordinate);
} catch {
throw new Error(
"The relay returned untrusted or unsupported Project State.",
);
}
}
function canonicalizePatch(
patch: ProjectRelatedChannelPatch,
): ProjectRelatedChannelPatch {
const add = [...patch.add].sort();
const remove = [...patch.remove].sort();
if (add.length > MAX_PATCH_CHANNELS || remove.length > MAX_PATCH_CHANNELS) {
throw new Error("A Project change may add or remove at most 64 channels.");
}
if (add.length === 0 && remove.length === 0) {
throw new Error("A Project related-channel change must not be empty.");
}
for (const channelId of [...add, ...remove]) {
if (!CANONICAL_UUID.test(channelId)) {
throw new Error("Related channels must use canonical lowercase UUIDs.");
}
}
if (
new Set(add).size !== add.length ||
new Set(remove).size !== remove.length
) {
throw new Error("A Project change must not contain duplicate channels.");
}
const removeSet = new Set(remove);
if (add.some((channelId) => removeSet.has(channelId))) {
throw new Error("A Project change cannot add and remove the same channel.");
}
return { add, remove };
}
/** Build the exact NIP-PC kind:47010 command template. */
export function buildProjectRelatedChannelChangeTemplate(
project: string | Pick<Project, "projectAddress">,
patch: ProjectRelatedChannelPatch,
expectedRevision: string,
): ProjectRelatedChannelChangeTemplate {
const coordinate = projectCoordinate(project);
parseProjectCoordinate(coordinate);
if (
!/^[1-9][0-9]*$/.test(expectedRevision) ||
BigInt(expectedRevision) > MAX_REVISION
) {
throw new Error("Expected Project revision is not canonical.");
}
const canonicalPatch = canonicalizePatch(patch);
return {
kind: KIND_PROJECT_CHANGE,
tags: [
["a", coordinate],
["expected-revision", expectedRevision],
],
content: JSON.stringify({
v: 1,
patch: { related_channels: canonicalPatch },
}),
};
}
function isRevisionConflict(error: unknown): boolean {
return (
error instanceof Error &&
error.message.includes("conflict: Project revision is ")
);
}
/** Publish a related-channel change, retrying one stale-revision conflict. */
export async function changeProjectRelatedChannels(
project: Pick<Project, "projectAddress">,
patch: ProjectRelatedChannelPatch,
deps?: Partial<ChangeProjectRelatedChannelsDeps>,
): Promise<void> {
const fetchDeps: Partial<ProjectStateDeps> = {
fetchEvents: deps?.fetchEvents,
getRelayPubkey: deps?.getRelayPubkey,
};
const signEvent = deps?.signEvent ?? signRelayEvent;
const publishEvent =
deps?.publishEvent ?? relayClient.publishEvent.bind(relayClient);
for (let attempt = 0; attempt < 2; attempt += 1) {
const state = await fetchProjectState(project, fetchDeps);
if (!state) {
throw new Error(
"Project State is not available yet. Refresh and try again.",
);
}
if (state.deleted) {
throw new Error("This Project has been deleted.");
}
const event = await signEvent(
buildProjectRelatedChannelChangeTemplate(project, patch, state.revision),
);
try {
await publishEvent(
event,
"Could not confirm the Project channel change. Refresh and try again.",
"Failed to change the Project's related channels.",
);
return;
} catch (error) {
if (attempt === 0 && isRevisionConflict(error)) continue;
throw error;
}
}
}

View File

@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import test from "node:test";
import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
import { buildProjectReadModels } from "./projectModels.ts";
const RELAY_SECRET = new Uint8Array(32).fill(9);
const RELAY_PUBKEY = getPublicKey(RELAY_SECRET);
const OWNER = "a".repeat(64);
const IDENTITY_ID = "b".repeat(64);
const COORDINATE = `30621:${OWNER}:buzz`;
const RELATED = "22222222-2222-4222-8222-222222222222";
function identityEvent() {
return {
id: IDENTITY_ID,
sig: "owner-signature-not-reverified-by-this-read-model",
kind: 30621,
pubkey: OWNER,
created_at: 200,
content: "",
tags: [
["d", "buzz"],
["name", "Owner name"],
],
};
}
function projection({ deleted = false, identityId = IDENTITY_ID } = {}) {
return finalizeEvent(
{
kind: 30623,
created_at: 201,
content: JSON.stringify({
v: 1,
deleted,
project_tags: deleted
? []
: [
["d", "buzz"],
["name", "Effective name"],
["buzz-related-channel", RELATED],
],
}),
tags: [
["d", "c".repeat(64)],
["a", COORDINATE],
["rev", "2"],
["e", identityId, "", "identity"],
["e", "d".repeat(64), "", "change"],
],
},
RELAY_SECRET,
);
}
test("read model uses verified effective tags while preserving owner identity fields", () => {
const [project] = buildProjectReadModels({
projectEvents: [identityEvent()],
projectStateEvents: [projection()],
repositoryEvents: [],
relayPubkey: RELAY_PUBKEY,
});
assert.equal(project.name, "Effective name");
assert.equal(project.owner, OWNER);
assert.equal(project.createdAt, 200);
assert.equal(project.id, COORDINATE);
assert.deepEqual(project.relatedChannelIds, [RELATED]);
});
test("read model suppresses matching deleted state and falls back for a stale identity marker", () => {
assert.deepEqual(
buildProjectReadModels({
projectEvents: [identityEvent()],
projectStateEvents: [projection({ deleted: true })],
repositoryEvents: [],
relayPubkey: RELAY_PUBKEY,
}),
[],
);
const [fallback] = buildProjectReadModels({
projectEvents: [identityEvent()],
projectStateEvents: [projection({ identityId: "e".repeat(64) })],
repositoryEvents: [],
relayPubkey: RELAY_PUBKEY,
});
assert.equal(fallback.name, "Owner name");
assert.deepEqual(fallback.relatedChannelIds, []);
});

View File

@@ -0,0 +1,99 @@
import { Hash } from "lucide-react";
import * as React from "react";
import type { Project } from "@/features/projects/projectModels";
import type { Channel } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
export function AddProjectChannelDialog({
channels,
isAdding,
onAdd,
onOpenChange,
open,
project,
}: {
channels: Channel[];
isAdding: boolean;
onAdd: (channel: Channel) => Promise<void>;
onOpenChange: (open: boolean) => void;
open: boolean;
project: Project;
}) {
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
React.useEffect(() => {
if (open) setErrorMessage(null);
}, [open]);
async function handleAdd(channel: Channel) {
setErrorMessage(null);
try {
await onAdd(channel);
onOpenChange(false);
} catch (error) {
setErrorMessage(
error instanceof Error
? error.message
: "Could not add the channel to this project.",
);
}
}
return (
<Dialog
onOpenChange={(nextOpen) => {
if (!nextOpen && isAdding) return;
onOpenChange(nextOpen);
}}
open={open}
>
<ChooserDialogContent
className="max-w-lg"
contentClassName="max-h-96 overflow-y-auto pt-3"
data-testid="add-project-channel-dialog"
description={`Choose an existing channel to add to ${project.name}.`}
title="Add existing channel"
>
<div className="space-y-2">
{channels.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Every available channel is already in this project.
</p>
) : (
channels.map((channel) => (
<Button
className="h-auto w-full justify-start gap-3 px-3 py-2.5 text-left"
data-testid={`add-existing-project-channel-${channel.id}`}
disabled={isAdding}
key={channel.id}
onClick={() => void handleAdd(channel)}
type="button"
variant="outline"
>
<Hash className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0">
<span className="block truncate font-medium">
{channel.name}
</span>
{channel.description ? (
<span className="block truncate text-xs font-normal text-muted-foreground">
{channel.description}
</span>
) : null}
</span>
</Button>
))
)}
{errorMessage ? (
<p className="text-sm text-destructive" role="alert">
{errorMessage}
</p>
) : null}
</div>
</ChooserDialogContent>
</Dialog>
);
}

View File

@@ -2,75 +2,58 @@ import { Plus } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { useIsManagedAgent } from "@/features/agent-memory/hooks";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { ownsAuthorAgent } from "@/features/profile/lib/identity";
import type { Project } from "@/features/projects/hooks";
import { useAddProjectChannelMutation } from "@/features/projects/useAddProjectChannel";
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
import { listLinkableProjectChannels } from "@/features/projects/lib/projectRelatedChannelAccess";
import { useChangeProjectRelatedChannelsMutation } from "@/features/projects/useChangeProjectRelatedChannels";
import type { Channel } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { AddProjectChannelDialog } from "./AddProjectChannelDialog";
export function ProjectChannelManagement({
identityPubkey,
canManage,
channels,
project,
}: {
identityPubkey?: string;
canManage: boolean;
channels: Channel[];
project: Project;
}) {
const { goChannel } = useAppNavigation();
const [createOpen, setCreateOpen] = React.useState(false);
const createMutation = useAddProjectChannelMutation();
const ownerProfileQuery = useUsersBatchQuery([project.owner], {
enabled: Boolean(identityPubkey),
});
const projectOwnerProfile =
ownerProfileQuery.data?.profiles[project.owner.toLowerCase()];
const projectOwnerIsManaged = useIsManagedAgent(project.owner) === true;
const viewerIsProjectOwner =
identityPubkey?.toLowerCase() === project.owner.toLowerCase();
const viewerOwnsProjectAgent = ownsAuthorAgent(
projectOwnerProfile,
identityPubkey,
const [addOpen, setAddOpen] = React.useState(false);
const changeMutation = useChangeProjectRelatedChannelsMutation();
const candidates = React.useMemo(
() => listLinkableProjectChannels(project, channels),
[channels, project],
);
const canEdit =
!project.legacy &&
(viewerIsProjectOwner || projectOwnerIsManaged || viewerOwnsProjectAgent);
const ownerControlAgentPubkey =
viewerOwnsProjectAgent && !projectOwnerIsManaged && !viewerIsProjectOwner
? project.owner
: undefined;
return (
<>
{canEdit ? (
<CreateChannelDialog
channelKind={createOpen ? "stream" : null}
description="Add another stream to this project. A template can keep the same canvas and agents."
isCreating={createMutation.isPending}
onCreate={async (input) => {
const result = await createMutation.mutateAsync({
...input,
ownerControlAgentPubkey,
{canManage ? (
<AddProjectChannelDialog
channels={candidates}
isAdding={changeMutation.isPending}
onAdd={async (channel) => {
await changeMutation.mutateAsync({
add: [channel.id],
project,
});
toast.success(`Channel "#${result.channel.name}" created.`);
await goChannel(result.channel.id);
toast.success(`Channel "#${channel.name}" added to project.`);
}}
onOpenChange={setCreateOpen}
testId="create-project-channel-dialog"
title="Create a project channel"
onOpenChange={setAddOpen}
open={addOpen}
project={project}
/>
) : null}
<Button
aria-label="Add channel"
className="h-6 w-6 shrink-0 rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
data-testid="add-project-channel"
disabled={!canEdit}
onClick={() => setCreateOpen(true)}
disabled={!canManage}
onClick={() => setAddOpen(true)}
size="icon"
title={
canEdit ? "Add channel" : "Only the project owner can add channels"
canManage
? "Add channel"
: "Only the project owner or a home-channel admin can add channels"
}
type="button"
variant="ghost"

View File

@@ -6,14 +6,22 @@ import {
GitCommitHorizontal,
GitPullRequest,
Hash,
Link2Off,
MoreHorizontal,
Users,
} from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import { canManageProjectRelatedChannels } from "@/features/projects/lib/projectRelatedChannelAccess";
import { presentContextCount } from "@/features/projects/lib/projectHomeSummary";
import type { ProjectHomeWorkspaceSheetTab } from "@/features/projects/lib/projectHomeWorkspaceSheet";
import { resolveProjectDefaultBranch } from "@/features/projects/lib/projectBranches";
import { listProjectBoundChannels } from "@/features/projects/lib/projectRelatedChannels";
import {
isExplicitProjectRelatedChannel,
listProjectBoundChannels,
} from "@/features/projects/lib/projectRelatedChannels";
import {
useProjectActivitySummariesQuery,
useProjectRepoSnapshotQuery,
@@ -21,10 +29,17 @@ import {
type Project,
} from "@/features/projects/hooks";
import { ProjectChannelIcon } from "@/features/projects/ui/ProjectChannelIcon";
import { useChangeProjectRelatedChannelsMutation } from "@/features/projects/useChangeProjectRelatedChannels";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import type { EntityLinkTab } from "@/shared/lib/entityLink";
import { Button } from "@/shared/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/shared/ui/context-menu";
import { ProjectChannelManagement } from "./ProjectChannelManagement";
import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement";
import { SECTION_ACTION_VISIBILITY_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles";
@@ -86,10 +101,12 @@ function ContextSection({
}
function ContextRowContent({
accessory,
children,
count,
icon,
}: {
accessory?: React.ReactNode;
children: React.ReactNode;
count?: number;
icon: React.ReactNode;
@@ -100,15 +117,18 @@ function ContextRowContent({
{icon}
</span>
<span className="min-w-0 flex-1 truncate text-left">{children}</span>
<span className="w-8 shrink-0 text-right tabular-nums text-current opacity-60">
{count ?? ""}
</span>
{accessory ?? (
<span className="w-8 shrink-0 text-right tabular-nums text-current opacity-60">
{count ?? ""}
</span>
)}
</>
);
}
function ContextNavButton({
children,
contextMenuTrigger = false,
count,
disabled,
icon,
@@ -118,6 +138,7 @@ function ContextNavButton({
title,
}: {
children: React.ReactNode;
contextMenuTrigger?: boolean;
count?: number;
disabled?: boolean;
icon: React.ReactNode;
@@ -126,11 +147,13 @@ function ContextNavButton({
testId?: string;
title?: string;
}) {
return (
const button = (
<Button
aria-haspopup={contextMenuTrigger ? "menu" : undefined}
aria-pressed={pressed}
className={cn(
PROJECT_HOME_SIDEBAR_ROW_CLASS,
contextMenuTrigger && "group/project-context-row",
pressed &&
"bg-sidebar-active text-sidebar-active-foreground shadow-xs hover:bg-sidebar-active hover:text-sidebar-active-foreground",
)}
@@ -138,44 +161,74 @@ function ContextNavButton({
disabled={disabled}
onClick={onClick}
size="sm"
title={title}
title={
contextMenuTrigger
? "Right-click or press Shift+F10 for project actions"
: title
}
type="button"
variant="ghost"
>
<ContextRowContent count={count} icon={icon}>
<ContextRowContent
accessory={
contextMenuTrigger ? (
<MoreHorizontal
aria-hidden="true"
className="size-4 shrink-0 opacity-0 transition-opacity group-hover/project-context-row:opacity-60 group-focus-visible/project-context-row:opacity-60"
/>
) : undefined
}
count={count}
icon={icon}
>
{children}
</ContextRowContent>
</Button>
);
return contextMenuTrigger ? (
<ContextMenuTrigger asChild>{button}</ContextMenuTrigger>
) : (
button
);
}
function ChannelContextRow({
channel,
contextMenuTrigger = false,
onClick,
projectHome,
testId,
}: {
channel: Channel;
contextMenuTrigger?: boolean;
onClick?: () => void;
projectHome?: boolean;
testId: string;
}) {
const Icon = projectHome ? ProjectChannelIcon : Hash;
if (onClick) {
return (
<ContextNavButton icon={<Icon />} onClick={onClick} testId={testId}>
let row: React.ReactNode;
if (onClick || contextMenuTrigger) {
row = (
<ContextNavButton
contextMenuTrigger={contextMenuTrigger}
icon={<Icon />}
onClick={onClick}
testId={testId}
>
{channel.name}
</ContextNavButton>
);
} else {
row = (
<div
className={`${PROJECT_HOME_SIDEBAR_ROW_CLASS} pointer-events-none flex items-center`}
data-testid={testId}
>
<ContextRowContent icon={<Icon />}>{channel.name}</ContextRowContent>
</div>
);
}
return (
<div
className={`${PROJECT_HOME_SIDEBAR_ROW_CLASS} pointer-events-none flex items-center`}
data-testid={testId}
>
<ContextRowContent icon={<Icon />}>{channel.name}</ContextRowContent>
</div>
);
return row;
}
export function ProjectHomeContextPanel({
@@ -234,6 +287,19 @@ export function ProjectHomeContextPanel({
null,
Boolean(firstRepository),
);
const homeChannelMembersQuery = useChannelMembersQuery(
project.projectChannelId,
);
const homeChannel = channels.find(
(candidate) => candidate.id === project.projectChannelId,
);
const canManageChannels = canManageProjectRelatedChannels({
homeChannelActive: homeChannel?.archivedAt === null,
homeChannelMembers: homeChannelMembersQuery.data,
identityPubkey,
project,
});
const changeChannelsMutation = useChangeProjectRelatedChannelsMutation();
const channelsById = new Map(
channels.map((candidate) => [candidate.id, candidate]),
);
@@ -325,7 +391,8 @@ export function ProjectHomeContextPanel({
collapsible
headerAction={
<ProjectChannelManagement
identityPubkey={identityPubkey}
canManage={canManageChannels}
channels={channels}
project={project}
/>
}
@@ -335,9 +402,14 @@ export function ProjectHomeContextPanel({
{listedChannels.length > 0 ? (
listedChannels.map((binding) => {
const isHome = binding.role === "home";
return (
const canRemove =
canManageChannels &&
!isHome &&
isExplicitProjectRelatedChannel(project, binding.channel.id);
const row = (
<ChannelContextRow
channel={binding.channel}
contextMenuTrigger={canRemove}
key={`${binding.role}:${binding.channel.id}`}
onClick={
isHome || !onOpenChannel
@@ -352,6 +424,39 @@ export function ProjectHomeContextPanel({
}
/>
);
if (!canRemove) return row;
return (
<ContextMenu key={`${binding.role}:${binding.channel.id}`}>
{row}
<ContextMenuContent>
<ContextMenuItem
disabled={changeChannelsMutation.isPending}
onSelect={() => {
void changeChannelsMutation
.mutateAsync({
project,
remove: [binding.channel.id],
})
.then(() => {
toast.success(
`Channel "#${binding.channel.name}" removed from project.`,
);
})
.catch((error: unknown) => {
toast.error(
error instanceof Error
? error.message
: "Could not remove the channel from this project.",
);
});
}}
>
<Link2Off />
Remove from project
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})
) : (
<p

View File

@@ -0,0 +1,27 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { projectsQueryKey } from "@/features/projects/hooks";
import type { Project } from "@/features/projects/projectModels";
import { changeProjectRelatedChannels } from "@/features/projects/projectState";
export type ChangeProjectRelatedChannelsInput = {
add?: string[];
project: Project;
remove?: string[];
};
export function useChangeProjectRelatedChannelsMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
add = [],
project,
remove = [],
}: ChangeProjectRelatedChannelsInput) =>
changeProjectRelatedChannels(project, { add, remove }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: projectsQueryKey });
},
});
}

View File

@@ -70,6 +70,9 @@ export const KIND_REPO_ANNOUNCEMENT = 30617;
export const KIND_REPO_STATE = 30618;
// NIP-MP: project grouping above NIP-34 repositories.
export const KIND_PROJECT_ANNOUNCEMENT = 30621;
// NIP-PC: relay-signed effective Project state and collaborative change command.
export const KIND_PROJECT_STATE = 30623;
export const KIND_PROJECT_CHANGE = 47010;
export const KIND_GIT_PATCH = 1617;
export const KIND_GIT_PULL_REQUEST = 1618;
export const KIND_GIT_PR_UPDATE = 1619;

View File

@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
import { CommunitiesProvider } from "@/features/communities/useCommunities";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { emit, listen } from "@tauri-apps/api/event";
import { mockIPC, mockWindows } from "@tauri-apps/api/mocks";
import { decode, npubEncode, nsecEncode } from "nostr-tools/nip19";
@@ -68,6 +69,8 @@ import {
KIND_MEMBER_REMOVED_NOTIFICATION,
KIND_PERSONA,
KIND_PROJECT_ANNOUNCEMENT,
KIND_PROJECT_CHANGE,
KIND_PROJECT_STATE,
KIND_REPO_ANNOUNCEMENT,
KIND_REPO_STATE,
KIND_STREAM_MESSAGE_EDIT,
@@ -215,6 +218,8 @@ type E2eConfig = {
projectHeadBranch?: string;
/** Override the repository access channel for project authorization states. */
projectAccessChannelId?: string;
/** Role held by the mock viewer in the Buzz Project's home channel. */
projectHomeRole?: "owner" | "admin" | "member";
/** Make remote project snapshots fail with this git-facing message. */
projectRepoSnapshotError?: string;
/** Delay remote repository snapshots so project loading UI is observable. */
@@ -1593,6 +1598,10 @@ const DEFAULT_REAL_IDENTITY = {
username: "tyler",
} satisfies TestIdentity;
// The mock relay uses a real key for relay-authored projections so production
// signature verification exercises the same trust boundary as a live relay.
const MOCK_PROJECT_RELAY_IDENTITY = DEFAULT_REAL_IDENTITY;
const ALICE_PUBKEY =
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
const BOB_PUBKEY =
@@ -6065,6 +6074,7 @@ const MOCK_PROJECT_SUBJECTS = [
const MOCK_PROJECT_KINDS = new Set<number>([
KIND_PROJECT_ANNOUNCEMENT,
KIND_PROJECT_STATE,
KIND_REPO_ANNOUNCEMENT,
KIND_REPO_STATE,
KIND_GIT_PATCH,
@@ -6231,33 +6241,224 @@ function buildMockProjectEvents(): RelayEvent[] {
if (!window.__BUZZ_E2E_REPOSITORY_ONLY_PROJECTS__) {
const projectOwner =
window.__BUZZ_E2E_PROJECT_OWNER_OVERRIDE__ ?? MOCK_PROJECT_SEEDS[0].owner;
events.push(
createMockEvent(
KIND_PROJECT_ANNOUNCEMENT,
"",
const identityEvent = createMockEvent(
KIND_PROJECT_ANNOUNCEMENT,
"",
[
["d", "buzz"],
["name", "buzz"],
["description", "The complete Buzz community platform."],
["a", `${KIND_REPO_ANNOUNCEMENT}:${projectOwner}:buzz`],
["a", `${KIND_REPO_ANNOUNCEMENT}:${ALICE_PUBKEY}:relay-tools`],
[
["d", "buzz"],
["name", "buzz"],
["description", "The complete Buzz community platform."],
["a", `${KIND_REPO_ANNOUNCEMENT}:${projectOwner}:buzz`],
["a", `${KIND_REPO_ANNOUNCEMENT}:${ALICE_PUBKEY}:relay-tools`],
[
"buzz-channel",
getConfig()?.mock?.projectAccessChannelId ??
STARTER_PROJECT_HOME_CHANNEL_ID,
],
["buzz-related-channel", "9dae0116-799b-5071-a0a8-fdd30a91a35d"],
"buzz-channel",
getConfig()?.mock?.projectAccessChannelId ??
STARTER_PROJECT_HOME_CHANNEL_ID,
],
projectOwner,
now,
"project-buzz".padEnd(64, "0"),
),
["buzz-related-channel", "9dae0116-799b-5071-a0a8-fdd30a91a35d"],
],
projectOwner,
now,
"b".repeat(64),
);
events.push(
identityEvent,
buildMockProjectStateEvent(identityEvent, 1, identityEvent.id),
);
}
return events;
}
function projectCoordinateFromIdentity(event: RelayEvent): string {
const dtag = event.tags.find((tag) => tag[0] === "d")?.[1];
if (!dtag) throw new Error("Mock Project identity is missing its d tag.");
return `${KIND_PROJECT_ANNOUNCEMENT}:${event.pubkey}:${dtag}`;
}
function buildMockProjectStateEvent(
identityEvent: RelayEvent,
revision: number,
changeEventId: string,
projectTags = identityEvent.tags,
): RelayEvent {
const coordinate = projectCoordinateFromIdentity(identityEvent);
return finalizeEvent(
{
kind: KIND_PROJECT_STATE,
content: JSON.stringify({
v: 1,
deleted: false,
project_tags: projectTags,
}),
tags: [
["d", bytesToHex(sha256(new TextEncoder().encode(coordinate)))],
["a", coordinate],
["rev", String(revision)],
["e", identityEvent.id, "", "identity"],
["e", changeEventId, "", "change"],
],
created_at: Math.floor(Date.now() / 1000) + revision,
},
hexToBytes(MOCK_PROJECT_RELAY_IDENTITY.privateKey),
);
}
function rejectMockProjectChange(
socket: MockSocket,
event: RelayEvent,
message: string,
): void {
sendWsText(socket.handler, ["OK", event.id, false, message]);
}
function applyMockProjectRelatedChannelChange(
socket: MockSocket,
event: RelayEvent,
): void {
const coordinateTags = event.tags.filter((tag) => tag[0] === "a");
const revisionTags = event.tags.filter(
(tag) => tag[0] === "expected-revision",
);
if (
event.tags.length !== 2 ||
coordinateTags.length !== 1 ||
coordinateTags[0].length !== 2 ||
revisionTags.length !== 1 ||
revisionTags[0].length !== 2
) {
rejectMockProjectChange(
socket,
event,
"invalid: malformed Project change tags",
);
return;
}
const coordinate = coordinateTags[0][1];
const store = getMockProjectEventStore();
const projectionIndex = store.findIndex(
(candidate) =>
candidate.kind === KIND_PROJECT_STATE &&
candidate.tags.some(
(tag) => tag.length === 2 && tag[0] === "a" && tag[1] === coordinate,
),
);
const projection = store[projectionIndex];
if (!projection) {
rejectMockProjectChange(socket, event, "invalid: Project State not found");
return;
}
const currentRevision = projection.tags.find((tag) => tag[0] === "rev")?.[1];
if (revisionTags[0][1] !== currentRevision) {
rejectMockProjectChange(
socket,
event,
`conflict: Project revision is ${currentRevision ?? "unknown"}`,
);
return;
}
let body: {
v: number;
patch: { related_channels: { add: string[]; remove: string[] } };
};
try {
body = JSON.parse(event.content) as typeof body;
} catch {
rejectMockProjectChange(
socket,
event,
"invalid: malformed Project change content",
);
return;
}
const patch = body?.patch?.related_channels;
if (
body?.v !== 1 ||
!patch ||
!Array.isArray(patch.add) ||
!Array.isArray(patch.remove) ||
patch.add.some((channelId) => typeof channelId !== "string") ||
patch.remove.some((channelId) => typeof channelId !== "string")
) {
rejectMockProjectChange(
socket,
event,
"invalid: malformed Project change content",
);
return;
}
const projectionBody = JSON.parse(projection.content) as {
project_tags: string[][];
};
const identityId = projection.tags.find(
(tag) => tag[0] === "e" && tag[3] === "identity",
)?.[1];
const identityEvent = store.find(
(candidate) =>
candidate.kind === KIND_PROJECT_ANNOUNCEMENT &&
candidate.id === identityId,
);
if (!identityEvent) {
rejectMockProjectChange(
socket,
event,
"invalid: Project identity not found",
);
return;
}
const related = new Set(
projectionBody.project_tags
.filter((tag) => tag[0] === "buzz-related-channel")
.map((tag) => tag[1]),
);
if (
patch.add.some((channelId) => related.has(channelId)) ||
patch.remove.some((channelId) => !related.has(channelId))
) {
rejectMockProjectChange(
socket,
event,
"invalid: Project related-channel patch is stale",
);
return;
}
for (const channelId of patch.remove) related.delete(channelId);
for (const channelId of patch.add) related.add(channelId);
const effectiveProjectTags = [
...projectionBody.project_tags.filter(
(tag) => tag[0] !== "buzz-related-channel",
),
...[...related]
.sort()
.map((channelId) => ["buzz-related-channel", channelId]),
];
const nextRevision = Number(currentRevision) + 1;
store.splice(
projectionIndex,
1,
buildMockProjectStateEvent(
identityEvent,
nextRevision,
event.id,
effectiveProjectTags,
),
);
const acceptedProjectEvents =
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__ ?? [];
acceptedProjectEvents.push({
content: event.content,
kind: event.kind,
tags: event.tags,
});
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__ = acceptedProjectEvents;
sendWsText(socket.handler, ["OK", event.id, true, ""]);
}
function getMockProjectEventStore(): RelayEvent[] {
if (!mockProjectEventStore) {
mockProjectEventStore = buildMockProjectEvents();
@@ -7367,8 +7568,18 @@ async function handleGetChannelMembers(
const identity = getIdentity(config);
if (!identity) {
const channel = getMockChannel(args.channelId);
const members = cloneMembers(channel.members);
if (
args.channelId === STARTER_PROJECT_HOME_CHANNEL_ID &&
config?.mock?.projectHomeRole
) {
const viewer = members.find(
(member) => member.pubkey === MOCK_IDENTITY_PUBKEY,
);
if (viewer) viewer.role = config.mock.projectHomeRole;
}
return {
members: cloneMembers(channel.members),
members,
next_cursor: null,
};
}
@@ -11077,6 +11288,11 @@ function sendToMockSocket(args: {
return;
}
if (event.kind === KIND_PROJECT_CHANGE) {
applyMockProjectRelatedChannelChange(socket, event);
return;
}
if (isMockProjectScopedEvent(event)) {
if (event.pubkey !== DEFAULT_MOCK_IDENTITY.pubkey) {
sendWsText(socket.handler, [
@@ -14504,7 +14720,9 @@ export function maybeInstallE2eTauriMocks() {
),
);
}
return activeConfig?.mock?.relaySelf ?? null;
return (
activeConfig?.mock?.relaySelf ?? MOCK_PROJECT_RELAY_IDENTITY.pubkey
);
case "archive_identity":
case "unarchive_identity":
// The spec only verifies UI state, not the submitted request shape;

View File

@@ -384,9 +384,9 @@ test("creating a project opens its channel conversation", async ({ page }) => {
await expect(repositoryAction).toHaveCSS("opacity", "1");
await page.getByTestId("project-home-context-channel").hover();
await page.getByTestId("add-project-channel").click();
await expect(page.getByTestId("create-project-channel-dialog")).toBeVisible();
await expect(page.getByTestId("add-project-channel-dialog")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("create-project-channel-dialog")).toBeHidden();
await expect(page.getByTestId("add-project-channel-dialog")).toBeHidden();
await expect(
page.getByTestId("sidebar-project-multi-repo-demo"),
).toBeVisible();

View File

@@ -0,0 +1,95 @@
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const WELCOME_CHANNEL_ID = "5f0b1b3c-2a37-5366-9b8c-31a4b21d8e77";
const WELCOME_CHANNEL_ROW = "project-home-context-channel-welcome-everyone";
async function openBuzzProject(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
await page.getByTestId("projects-section-projects").click();
const projectEntry = page
.locator(
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
)
.first();
await expect(projectEntry).toBeVisible({ timeout: 10_000 });
await projectEntry.click();
await expect(page.getByTestId("project-home-context-panel")).toBeVisible();
}
async function addWelcomeChannel(page: import("@playwright/test").Page) {
await page.getByTestId("project-home-context-channel").hover();
await page.getByTestId("add-project-channel").click();
const dialog = page.getByTestId("add-project-channel-dialog");
await expect(dialog).toBeVisible();
await dialog
.getByTestId(`add-existing-project-channel-${WELCOME_CHANNEL_ID}`)
.click();
await expect(dialog).toBeHidden();
await expect(page.getByTestId(WELCOME_CHANNEL_ROW)).toBeVisible();
}
async function acceptedProjectChanges(page: import("@playwright/test").Page) {
return page.evaluate(() =>
(window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__ ?? [])
.filter((event) => event.kind === 47010)
.map((event) => ({
content: JSON.parse(event.content),
tags: event.tags,
})),
);
}
test("Project owner links an existing channel", async ({ page }) => {
await installMockBridge(page);
await openBuzzProject(page);
await addWelcomeChannel(page);
await expect
.poll(async () => (await acceptedProjectChanges(page)).length)
.toBe(1);
const [change] = await acceptedProjectChanges(page);
expect(change.tags).toEqual([
["a", `30621:${"deadbeef".repeat(8)}:buzz`],
["expected-revision", "1"],
]);
expect(change.content).toEqual({
v: 1,
patch: {
related_channels: { add: [WELCOME_CHANNEL_ID], remove: [] },
},
});
});
test("home-channel admin links and removes an existing channel", async ({
page,
}) => {
await page.addInitScript((owner) => {
window.__BUZZ_E2E_PROJECT_OWNER_OVERRIDE__ = owner;
}, TEST_IDENTITIES.alice.pubkey);
await installMockBridge(page, { projectHomeRole: "admin" });
await openBuzzProject(page);
await addWelcomeChannel(page);
const row = page.getByTestId(WELCOME_CHANNEL_ROW);
await row.click({ button: "right" });
await page.getByRole("menuitem", { name: "Remove from project" }).click();
await expect(row).toHaveCount(0);
await expect
.poll(async () => (await acceptedProjectChanges(page)).length)
.toBe(2);
const [add, remove] = await acceptedProjectChanges(page);
expect(add.tags).toContainEqual(["expected-revision", "1"]);
expect(remove.tags).toContainEqual(["expected-revision", "2"]);
expect(add.content.patch.related_channels).toEqual({
add: [WELCOME_CHANNEL_ID],
remove: [],
});
expect(remove.content.patch.related_channels).toEqual({
add: [],
remove: [WELCOME_CHANNEL_ID],
});
});

View File

@@ -156,6 +156,8 @@ type MockBridgeOptions = {
pocketVoiceImportResult?: "success" | "cancel" | "invalid";
/** Advertised HEAD for the first mock project without adding that branch. */
projectHeadBranch?: string;
/** Role held by the mock viewer in the Buzz Project's home channel. */
projectHomeRole?: "owner" | "admin" | "member";
/** Relay NIP-11 identity used to sign authoritative repository state. */
relaySelf?: string | null;
/** Native-like huddle state seeded from authoritative role-bearing membership. */