mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
Add related channel management to project home
Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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] },
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
173
desktop/src/features/projects/projectState.test.mjs
Normal file
173
desktop/src/features/projects/projectState.test.mjs
Normal 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"],
|
||||
);
|
||||
});
|
||||
355
desktop/src/features/projects/projectState.ts
Normal file
355
desktop/src/features/projects/projectState.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
90
desktop/src/features/projects/projectStateReadModel.test.mjs
Normal file
90
desktop/src/features/projects/projectStateReadModel.test.mjs
Normal 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, []);
|
||||
});
|
||||
99
desktop/src/features/projects/ui/AddProjectChannelDialog.tsx
Normal file
99
desktop/src/features/projects/ui/AddProjectChannelDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
95
desktop/tests/e2e/project-related-channels.spec.ts
Normal file
95
desktop/tests/e2e/project-related-channels.spec.ts
Normal 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],
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user