mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
perf(desktop): restore project context during startup (#6939)
## Summary Project channels previously appeared as ordinary channels until the community-wide project enumeration completed, delaying the contextual right rail most noticeably on larger relays. - Restore the last fully validated, relay-and-identity-scoped project collection immediately while keeping live relay data authoritative. - Resolve the active channel's project home through scoped `#buzz-channel` queries instead of waiting for the complete project scan. - Keep snapshots aligned with community removal and relay reconnect invalidation. ### Related issue None found. ### Testing - Pre-push `file-size-check`, `desktop-check`, `desktop-typecheck`, and `desktop-test` - Project enumeration, snapshot persistence, and relay invalidation unit coverage - Targeted Playwright scenario: project sidebar rows open the home channel and nest extra channels No numeric startup benchmark was captured; this draft validates the cache and scoped-query behavior while leaving timing measurement for review. --------- Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
@@ -119,6 +119,7 @@ export default defineConfig({
|
||||
"**/inbox-reactions.spec.ts",
|
||||
"**/inbox-edit.spec.ts",
|
||||
"**/send-channel-binding.spec.ts",
|
||||
"**/project-cold-start.spec.ts",
|
||||
"**/project-commit-detail.spec.ts",
|
||||
"**/project-inbox.spec.ts",
|
||||
"**/projects-v3-screenshots.spec.ts",
|
||||
|
||||
@@ -61,6 +61,7 @@ import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup";
|
||||
import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen";
|
||||
import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay";
|
||||
import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync";
|
||||
import { seedProjectSnapshot } from "@/features/projects/projectSnapshot";
|
||||
import { EncryptedBackupProvider } from "@/features/settings/EncryptedBackupProvider";
|
||||
import { createBuzzQueryClient } from "@/shared/api/queryClient";
|
||||
import { hydrateChannelHeads } from "@/features/messages/lib/channelHeadCache";
|
||||
@@ -234,6 +235,7 @@ function CommunityQueryProvider({
|
||||
const [queryClient] = useState(() => {
|
||||
const client = createBuzzQueryClient();
|
||||
if (pubkey && relayUrl) {
|
||||
seedProjectSnapshot(client, { pubkey, relayUrl });
|
||||
void hydrateChannelHeads(client, { pubkey, relayUrl });
|
||||
}
|
||||
return client;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation";
|
||||
import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache";
|
||||
@@ -13,8 +14,16 @@ import {
|
||||
isBroadcastReply,
|
||||
} from "@/features/messages/lib/threading";
|
||||
import { useProfileQuery } from "@/features/profile/hooks";
|
||||
import { useProjectsQuery } from "@/features/projects/hooks";
|
||||
import {
|
||||
useProjectHomeForChannelQuery,
|
||||
useProjectsQuery,
|
||||
} from "@/features/projects/hooks";
|
||||
import { findProjectHomeByChannelId } from "@/features/projects/lib/projectHomeChannel";
|
||||
import {
|
||||
isProjectCollectionAuthoritative,
|
||||
isProjectRelayValidated,
|
||||
shouldUseScopedProjectHomeLookup,
|
||||
} from "@/features/projects/projectSnapshot";
|
||||
import { ProjectChannelHome } from "@/features/projects/ui/ProjectChannelHome";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { getEventById } from "@/shared/api/tauri";
|
||||
@@ -112,6 +121,7 @@ export function ChannelRouteScreen({
|
||||
targetThreadRootId,
|
||||
}: ChannelRouteScreenProps) {
|
||||
const isHuddleTranscript = huddleWindowChannelId() !== null;
|
||||
const queryClient = useQueryClient();
|
||||
const { closeForumPost, goForumPost } = useAppNavigation();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const projectsQuery = useProjectsQuery();
|
||||
@@ -133,10 +143,22 @@ export function ChannelRouteScreen({
|
||||
memberChannel ??
|
||||
openDirectoryQuery.data?.find((channel) => channel.id === channelId) ??
|
||||
null;
|
||||
const projectHome = findProjectHomeByChannelId(
|
||||
const enumeratedProjectHome = findProjectHomeByChannelId(
|
||||
channelId,
|
||||
projectsQuery.data ?? [],
|
||||
);
|
||||
const projectCollectionIsAuthoritative =
|
||||
isProjectCollectionAuthoritative(queryClient);
|
||||
const projectHomeLookupQuery = useProjectHomeForChannelQuery(
|
||||
channelId,
|
||||
shouldUseScopedProjectHomeLookup({
|
||||
collectionIsAuthoritative: projectCollectionIsAuthoritative,
|
||||
hasEnumeratedProjectHome: Boolean(enumeratedProjectHome),
|
||||
isHuddleTranscript,
|
||||
}),
|
||||
);
|
||||
const projectHome =
|
||||
enumeratedProjectHome ?? projectHomeLookupQuery.data ?? null;
|
||||
const [targetMessageEvents, setTargetMessageEvents] = React.useState<
|
||||
RelayEvent[]
|
||||
>(() => {
|
||||
@@ -276,6 +298,7 @@ export function ChannelRouteScreen({
|
||||
if (projectHome && !isHuddleTranscript) {
|
||||
return (
|
||||
<ProjectChannelHome
|
||||
allowRepositoryHealing={isProjectRelayValidated(projectHome)}
|
||||
autoSendDraftKey={autoSendDraftKey}
|
||||
project={projectHome}
|
||||
projects={projectsQuery.data ?? [projectHome]}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { removeSelfProfileCachesForRelay } from "@/features/profile/lib/selfProfileStorage";
|
||||
import { removeUserLabelCacheForRelay } from "@/features/profile/lib/userLabelStorage";
|
||||
import { removeChannelSnapshotForRelay } from "@/features/channels/channelSnapshot";
|
||||
import { removeProjectSnapshotForRelay } from "@/features/projects/projectSnapshot";
|
||||
import { clearChannelHeadCache } from "@/shared/api/tauriChannelHeadCache";
|
||||
import { getIdentity } from "@/shared/api/tauriIdentity";
|
||||
import { clearSavedCommunitySnapshot } from "@/features/agents/activeAgentTurnsStore";
|
||||
@@ -235,6 +236,7 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
|
||||
removeSelfProfileCachesForRelay(removed.relayUrl);
|
||||
removeUserLabelCacheForRelay(removed.relayUrl);
|
||||
removeChannelSnapshotForRelay(removed.relayUrl);
|
||||
removeProjectSnapshotForRelay(removed.relayUrl);
|
||||
void getIdentity()
|
||||
.then((identity) =>
|
||||
clearChannelHeadCache({
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as React from "react";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { getRelaySelf } from "@/features/moderation/lib/relaySelf";
|
||||
import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl";
|
||||
import { signRelayEvent } from "@/shared/api/tauri";
|
||||
import { getIdentity } from "@/shared/api/tauriIdentity";
|
||||
import {
|
||||
@@ -67,13 +66,15 @@ import {
|
||||
type Project,
|
||||
type Repository,
|
||||
} from "./projectModels";
|
||||
import { fetchProjectHomeForChannel, fetchProjects } from "./projectFetch";
|
||||
import {
|
||||
buildProjectsFromFetcher,
|
||||
type FetchProjectEventsExhaustively,
|
||||
fetchProjectEventsExhaustively,
|
||||
} from "./projectEnumeration";
|
||||
markProjectCollectionAuthoritative,
|
||||
persistProjectSnapshot,
|
||||
PROJECT_QUERY_STRUCTURAL_SHARING,
|
||||
} from "./projectSnapshot";
|
||||
import { projectMatchesRouteId } from "./projectRoutes";
|
||||
|
||||
export { fetchProjects } from "./projectFetch";
|
||||
export { projectsQueryKey };
|
||||
|
||||
export type {
|
||||
@@ -85,7 +86,6 @@ export type {
|
||||
};
|
||||
export type ProjectPullRequestCommentDecision = "request-changes";
|
||||
|
||||
const HIDDEN_PROJECT_CARDS_KEY = "buzz.projects.hidden-cards.v1";
|
||||
export type RepoState = {
|
||||
branches: Array<{ name: string; commit: string }>;
|
||||
tags: Array<{ name: string; commit: string }>;
|
||||
@@ -134,23 +134,6 @@ export type ProjectIssueListItem = {
|
||||
issue: ProjectIssue;
|
||||
};
|
||||
|
||||
function readHiddenProjectCards(): string[] {
|
||||
if (typeof window === "undefined") {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
window.localStorage.getItem(HIDDEN_PROJECT_CARDS_KEY) ?? "[]",
|
||||
);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a kind:30617 repo announcement into a `Project`.
|
||||
*
|
||||
@@ -170,27 +153,6 @@ export function eventToProject(
|
||||
return repository;
|
||||
}
|
||||
|
||||
export async function fetchProjects(
|
||||
fetchExhaustively?: FetchProjectEventsExhaustively,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Project[]> {
|
||||
// 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 fetcher: FetchProjectEventsExhaustively =
|
||||
fetchExhaustively ??
|
||||
((kinds, extraFilter) =>
|
||||
fetchProjectEventsExhaustively(kinds, extraFilter, undefined, signal));
|
||||
return buildProjectsFromFetcher(fetcher, {
|
||||
relayOrigin: getCachedRelayOrigin(),
|
||||
hiddenAddresses: new Set(readHiddenProjectCards()),
|
||||
viewerPubkey,
|
||||
});
|
||||
}
|
||||
|
||||
function eventToRepoState(event: RelayEvent): RepoState {
|
||||
const branches: RepoState["branches"] = [];
|
||||
const tags: RepoState["tags"] = [];
|
||||
@@ -650,24 +612,51 @@ export const PROJECT_ACTIVITY_STALE_TIME_MS = 2 * 60_000;
|
||||
export const PROJECT_LOCAL_REPOS_STALE_TIME_MS = 2 * 60_000;
|
||||
|
||||
export function useProjectsQuery(enabled = true) {
|
||||
const queryClient = useQueryClient();
|
||||
return useQuery({
|
||||
queryKey: projectsQueryKey,
|
||||
queryFn: ({ signal }) => fetchProjects(undefined, signal),
|
||||
queryFn: async ({ signal }) => {
|
||||
const projects = await fetchProjects(undefined, signal);
|
||||
markProjectCollectionAuthoritative(queryClient);
|
||||
persistProjectSnapshot(queryClient, projects);
|
||||
return projects;
|
||||
},
|
||||
staleTime: PROJECTS_STALE_TIME_MS,
|
||||
gcTime: PROJECTS_GC_TIME_MS,
|
||||
structuralSharing: PROJECT_QUERY_STRUCTURAL_SHARING,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProjectHomeForChannelQuery(
|
||||
channelId: string,
|
||||
enabled = true,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["projects", "home-channel", channelId],
|
||||
queryFn: ({ signal }) => fetchProjectHomeForChannel(channelId, signal),
|
||||
staleTime: PROJECTS_STALE_TIME_MS,
|
||||
gcTime: PROJECTS_GC_TIME_MS,
|
||||
enabled: enabled && channelId.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProjectQuery(projectId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useQuery({
|
||||
queryKey: projectsQueryKey,
|
||||
queryFn: ({ signal }) => fetchProjects(undefined, signal),
|
||||
queryFn: async ({ signal }) => {
|
||||
const projects = await fetchProjects(undefined, signal);
|
||||
markProjectCollectionAuthoritative(queryClient);
|
||||
persistProjectSnapshot(queryClient, projects);
|
||||
return projects;
|
||||
},
|
||||
select: (projects) =>
|
||||
projects.find((project) => projectMatchesRouteId(project, projectId)) ??
|
||||
null,
|
||||
staleTime: PROJECTS_STALE_TIME_MS,
|
||||
gcTime: PROJECTS_GC_TIME_MS,
|
||||
structuralSharing: PROJECT_QUERY_STRUCTURAL_SHARING,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,64 +1,12 @@
|
||||
import { useProjectsQuery } from "@/features/projects/hooks";
|
||||
import type { Project } from "@/features/projects/projectModels";
|
||||
import { isProjectHomeChannel } from "./projectHomeSelection";
|
||||
|
||||
/** Resolves the canonical visible project home for a channel. */
|
||||
export function findProjectHomeByChannelId(
|
||||
channelId: string | null | undefined,
|
||||
projects: readonly Project[],
|
||||
): Project | null {
|
||||
if (!channelId) return null;
|
||||
const matching = projects
|
||||
.filter(
|
||||
(project) =>
|
||||
!project.legacy &&
|
||||
project.projectChannelId === channelId &&
|
||||
hasAuthoritativeHomeBinding(project),
|
||||
)
|
||||
.sort((left, right) => left.createdAt - right.createdAt);
|
||||
return (
|
||||
matching.find((project) => project.visibility !== "unlisted") ??
|
||||
matching[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export type ProjectHomeCandidate = {
|
||||
owner: string;
|
||||
projectChannelId: string | null;
|
||||
repositories: ReadonlyArray<{
|
||||
channelId?: string | null;
|
||||
maintainers?: ReadonlyArray<string>;
|
||||
owner: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function hasAuthoritativeHomeBinding(
|
||||
project: ProjectHomeCandidate,
|
||||
): boolean {
|
||||
const channelId = project.projectChannelId;
|
||||
if (!channelId) return false;
|
||||
|
||||
const projectOwner = project.owner.toLowerCase();
|
||||
return project.repositories.some((repository) => {
|
||||
if (repository.channelId !== channelId) return false;
|
||||
if (repository.owner.toLowerCase() === projectOwner) return true;
|
||||
return repository.maintainers?.some(
|
||||
(maintainer) => maintainer.toLowerCase() === projectOwner,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function isProjectHomeChannel(
|
||||
channelId: string | null | undefined,
|
||||
projects: ReadonlyArray<ProjectHomeCandidate>,
|
||||
): boolean {
|
||||
if (!channelId) return false;
|
||||
return projects.some(
|
||||
(project) =>
|
||||
project.projectChannelId === channelId &&
|
||||
hasAuthoritativeHomeBinding(project),
|
||||
);
|
||||
}
|
||||
export {
|
||||
findProjectHomeByChannelId,
|
||||
hasAuthoritativeHomeBinding,
|
||||
isProjectHomeChannel,
|
||||
type ProjectHomeCandidate,
|
||||
} from "./projectHomeSelection";
|
||||
|
||||
export function useIsProjectHomeChannel(channelId: string | null | undefined) {
|
||||
const projectsQuery = useProjectsQuery();
|
||||
|
||||
60
desktop/src/features/projects/lib/projectHomeSelection.ts
Normal file
60
desktop/src/features/projects/lib/projectHomeSelection.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Project } from "@/features/projects/projectModels";
|
||||
|
||||
/** Resolves the canonical visible project home for a channel. */
|
||||
export function findProjectHomeByChannelId(
|
||||
channelId: string | null | undefined,
|
||||
projects: readonly Project[],
|
||||
): Project | null {
|
||||
if (!channelId) return null;
|
||||
const matching = projects
|
||||
.filter(
|
||||
(project) =>
|
||||
!project.legacy &&
|
||||
project.projectChannelId === channelId &&
|
||||
hasAuthoritativeHomeBinding(project),
|
||||
)
|
||||
.sort((left, right) => left.createdAt - right.createdAt);
|
||||
return (
|
||||
matching.find((project) => project.visibility !== "unlisted") ??
|
||||
matching[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export type ProjectHomeCandidate = {
|
||||
owner: string;
|
||||
projectChannelId: string | null;
|
||||
repositories: ReadonlyArray<{
|
||||
channelId?: string | null;
|
||||
maintainers?: ReadonlyArray<string>;
|
||||
owner: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function hasAuthoritativeHomeBinding(
|
||||
project: ProjectHomeCandidate,
|
||||
): boolean {
|
||||
const channelId = project.projectChannelId;
|
||||
if (!channelId) return false;
|
||||
|
||||
const projectOwner = project.owner.toLowerCase();
|
||||
return project.repositories.some((repository) => {
|
||||
if (repository.channelId !== channelId) return false;
|
||||
if (repository.owner.toLowerCase() === projectOwner) return true;
|
||||
return repository.maintainers?.some(
|
||||
(maintainer) => maintainer.toLowerCase() === projectOwner,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function isProjectHomeChannel(
|
||||
channelId: string | null | undefined,
|
||||
projects: ReadonlyArray<ProjectHomeCandidate>,
|
||||
): boolean {
|
||||
if (!channelId) return false;
|
||||
return projects.some(
|
||||
(project) =>
|
||||
project.projectChannelId === channelId &&
|
||||
hasAuthoritativeHomeBinding(project),
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
enumerateProjectEvents,
|
||||
buildProjectHomeFromFetcher,
|
||||
buildProjectsFromFetcher,
|
||||
} from "./projectEnumeration.ts";
|
||||
|
||||
@@ -32,6 +33,64 @@ function fetcherFor(events) {
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
test("buildProjectHomeFromFetcher scopes startup lookup to the active channel", async () => {
|
||||
const owner = "a".repeat(64);
|
||||
const channelId = "11111111-1111-4111-8111-111111111111";
|
||||
const repositoryAddress = `30617:${owner}:relay`;
|
||||
const projectEvent = {
|
||||
id: "p".repeat(64),
|
||||
kind: 30621,
|
||||
pubkey: owner,
|
||||
created_at: 200,
|
||||
content: "",
|
||||
tags: [
|
||||
["d", "relay"],
|
||||
["name", "Relay"],
|
||||
["buzz-channel", channelId],
|
||||
["a", repositoryAddress],
|
||||
],
|
||||
};
|
||||
const repositoryEvent = {
|
||||
id: "r".repeat(64),
|
||||
kind: 30617,
|
||||
pubkey: owner,
|
||||
created_at: 100,
|
||||
content: "",
|
||||
tags: [
|
||||
["d", "relay"],
|
||||
["name", "Relay"],
|
||||
["buzz-channel", channelId],
|
||||
],
|
||||
};
|
||||
const calls = [];
|
||||
const fetchExhaustively = async (kinds, extraFilter) => {
|
||||
calls.push({ kinds, extraFilter });
|
||||
if (kinds.includes(30621)) return [projectEvent];
|
||||
if (kinds.includes(30617)) return [repositoryEvent];
|
||||
return [];
|
||||
};
|
||||
|
||||
const project = await buildProjectHomeFromFetcher(
|
||||
fetchExhaustively,
|
||||
channelId,
|
||||
{ viewerPubkey: owner },
|
||||
);
|
||||
|
||||
assert.equal(project?.projectChannelId, channelId);
|
||||
assert.deepEqual(calls[0], {
|
||||
kinds: [30621],
|
||||
extraFilter: { "#buzz-channel": [channelId] },
|
||||
});
|
||||
assert.deepEqual(calls[1], {
|
||||
kinds: [30617],
|
||||
extraFilter: { "#buzz-channel": [channelId] },
|
||||
});
|
||||
assert.deepEqual(calls[2], {
|
||||
kinds: [5],
|
||||
extraFilter: { "#a": [`30621:${owner}:relay`, repositoryAddress] },
|
||||
});
|
||||
});
|
||||
|
||||
test("enumerateProjectEvents drains a tied boundary second before advancing", async () => {
|
||||
const events = [
|
||||
relayEvent("a", 1_000),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
KIND_REPO_ANNOUNCEMENT,
|
||||
} from "@/shared/constants/kinds";
|
||||
import { absorbStandaloneProjectRepositories } from "./lib/projectCollection";
|
||||
import { findProjectHomeByChannelId } from "./lib/projectHomeSelection";
|
||||
import { buildProjectReadModels, type Project } from "./projectModels";
|
||||
|
||||
const PROJECT_ENUMERATION_PAGE_SIZE = 500;
|
||||
@@ -17,6 +18,7 @@ const TOMBSTONE_COORDINATE_CHUNK_SIZE = 100;
|
||||
/** Additional server-side scoping merged into every enumeration page. */
|
||||
export type ProjectEventExtraFilter = {
|
||||
"#a"?: string[];
|
||||
"#buzz-channel"?: string[];
|
||||
};
|
||||
|
||||
type ProjectEventFilter = ProjectEventExtraFilter & {
|
||||
@@ -213,3 +215,29 @@ export async function buildProjectsFromFetcher(
|
||||
}),
|
||||
).sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves one channel's authoritative project home without waiting for the
|
||||
* community-wide project enumeration used by the sidebar and Projects view.
|
||||
*/
|
||||
export async function buildProjectHomeFromFetcher(
|
||||
fetchExhaustively: FetchProjectEventsExhaustively,
|
||||
channelId: string,
|
||||
options: {
|
||||
relayOrigin?: string | null;
|
||||
hiddenAddresses?: ReadonlySet<string>;
|
||||
viewerPubkey?: string | null;
|
||||
} = {},
|
||||
): Promise<Project | null> {
|
||||
const projects = await buildProjectsFromFetcher(
|
||||
(kinds, extraFilter) =>
|
||||
fetchExhaustively(
|
||||
kinds,
|
||||
kinds.includes(KIND_DELETION)
|
||||
? extraFilter
|
||||
: { ...extraFilter, "#buzz-channel": [channelId] },
|
||||
),
|
||||
options,
|
||||
);
|
||||
return findProjectHomeByChannelId(channelId, projects);
|
||||
}
|
||||
|
||||
75
desktop/src/features/projects/projectFetch.ts
Normal file
75
desktop/src/features/projects/projectFetch.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl";
|
||||
import { getIdentity } from "@/shared/api/tauriIdentity";
|
||||
import {
|
||||
buildProjectHomeFromFetcher,
|
||||
buildProjectsFromFetcher,
|
||||
type FetchProjectEventsExhaustively,
|
||||
fetchProjectEventsExhaustively,
|
||||
} from "./projectEnumeration";
|
||||
import type { Project } from "./projectModels";
|
||||
import { markProjectDataAuthoritative } from "./projectSnapshot";
|
||||
|
||||
const HIDDEN_PROJECT_CARDS_KEY = "buzz.projects.hidden-cards.v1";
|
||||
|
||||
function readHiddenProjectCards(): string[] {
|
||||
if (typeof window === "undefined") {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
window.localStorage.getItem(HIDDEN_PROJECT_CARDS_KEY) ?? "[]",
|
||||
);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Enumerates the projects visible to the current relay identity. */
|
||||
export async function fetchProjects(
|
||||
fetchExhaustively?: FetchProjectEventsExhaustively,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Project[]> {
|
||||
// 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 fetcher: FetchProjectEventsExhaustively =
|
||||
fetchExhaustively ??
|
||||
((kinds, extraFilter) =>
|
||||
fetchProjectEventsExhaustively(kinds, extraFilter, undefined, signal));
|
||||
const projects = await buildProjectsFromFetcher(fetcher, {
|
||||
relayOrigin: getCachedRelayOrigin(),
|
||||
hiddenAddresses: new Set(readHiddenProjectCards()),
|
||||
viewerPubkey,
|
||||
});
|
||||
return projects.map((project) =>
|
||||
markProjectDataAuthoritative(project, "relay"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves the active channel's project home with a scoped relay query. */
|
||||
export async function fetchProjectHomeForChannel(
|
||||
channelId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Project | null> {
|
||||
const viewerPubkey = await getIdentity()
|
||||
.then((identity) => identity.pubkey)
|
||||
.catch(() => undefined);
|
||||
const project = await buildProjectHomeFromFetcher(
|
||||
(kinds, extraFilter) =>
|
||||
fetchProjectEventsExhaustively(kinds, extraFilter, undefined, signal),
|
||||
channelId,
|
||||
{
|
||||
relayOrigin: getCachedRelayOrigin(),
|
||||
hiddenAddresses: new Set(readHiddenProjectCards()),
|
||||
viewerPubkey,
|
||||
},
|
||||
);
|
||||
return project ? markProjectDataAuthoritative(project, "relay") : null;
|
||||
}
|
||||
155
desktop/src/features/projects/projectSnapshot.test.mjs
Normal file
155
desktop/src/features/projects/projectSnapshot.test.mjs
Normal file
@@ -0,0 +1,155 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
isProjectCollectionAuthoritative,
|
||||
isProjectDataAuthoritative,
|
||||
isProjectRelayValidated,
|
||||
markProjectCollectionAuthoritative,
|
||||
markProjectDataAuthoritative,
|
||||
persistProjectSnapshot,
|
||||
PROJECT_QUERY_STRUCTURAL_SHARING,
|
||||
projectSnapshotKey,
|
||||
readProjectSnapshot,
|
||||
removeProjectSnapshotForRelay,
|
||||
seedProjectSnapshot,
|
||||
shouldUseScopedProjectHomeLookup,
|
||||
} from "./projectSnapshot.ts";
|
||||
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
const storage = new Map();
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
get length() {
|
||||
return storage.size;
|
||||
},
|
||||
key: (index) => [...storage.keys()][index] ?? null,
|
||||
getItem: (key) => storage.get(key) ?? null,
|
||||
setItem: (key, value) => storage.set(key, value),
|
||||
removeItem: (key) => storage.delete(key),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const RELAY = "wss://relay.example.com";
|
||||
const OWNER = "a".repeat(64);
|
||||
const PROJECT = {
|
||||
id: `30621:${OWNER}:relay`,
|
||||
dtag: "relay",
|
||||
name: "Relay",
|
||||
description: "",
|
||||
owner: OWNER,
|
||||
createdAt: 100,
|
||||
projectChannelId: "11111111-1111-4111-8111-111111111111",
|
||||
relatedChannelIds: [],
|
||||
status: "active",
|
||||
projectAddress: `30621:${OWNER}:relay`,
|
||||
primaryRepositoryAddress: null,
|
||||
repositoryAddresses: [],
|
||||
repositoryRelayHints: {},
|
||||
repositories: [],
|
||||
unavailableRepositoryAddresses: [],
|
||||
visibility: "listed",
|
||||
legacy: false,
|
||||
};
|
||||
|
||||
test.beforeEach(() => {
|
||||
removeProjectSnapshotForRelay(RELAY);
|
||||
});
|
||||
|
||||
test("project snapshot is scoped by normalized relay and identity", () => {
|
||||
const client = new QueryClient();
|
||||
seedProjectSnapshot(client, { pubkey: OWNER, relayUrl: RELAY });
|
||||
persistProjectSnapshot(client, [PROJECT]);
|
||||
|
||||
assert.deepEqual(readProjectSnapshot(RELAY, OWNER), [PROJECT]);
|
||||
assert.equal(readProjectSnapshot(RELAY, "b".repeat(64)), null);
|
||||
assert.equal(
|
||||
projectSnapshotKey("WSS://Relay.Example.com/", OWNER.toUpperCase()),
|
||||
projectSnapshotKey(RELAY, OWNER),
|
||||
);
|
||||
});
|
||||
|
||||
test("seedProjectSnapshot paints stale data into a fresh query client", () => {
|
||||
const writer = new QueryClient();
|
||||
seedProjectSnapshot(writer, { pubkey: OWNER, relayUrl: RELAY });
|
||||
persistProjectSnapshot(writer, [PROJECT]);
|
||||
|
||||
const reader = new QueryClient();
|
||||
seedProjectSnapshot(reader, { pubkey: OWNER, relayUrl: RELAY });
|
||||
|
||||
assert.deepEqual(reader.getQueryData(["projects"]), [PROJECT]);
|
||||
assert.equal(reader.getQueryState(["projects"])?.dataUpdatedAt, 0);
|
||||
const snapshotProject = reader.getQueryData(["projects"])[0];
|
||||
assert.equal(isProjectDataAuthoritative(snapshotProject), false);
|
||||
assert.equal(isProjectCollectionAuthoritative(reader), false);
|
||||
|
||||
const locallyWrittenProject = markProjectDataAuthoritative(
|
||||
{ ...PROJECT, id: `${PROJECT.id}:local` },
|
||||
"local-write",
|
||||
);
|
||||
reader.setQueryData(["projects"], (current = []) => [
|
||||
...current,
|
||||
locallyWrittenProject,
|
||||
]);
|
||||
|
||||
assert.ok((reader.getQueryState(["projects"])?.dataUpdatedAt ?? 0) > 0);
|
||||
assert.equal(isProjectCollectionAuthoritative(reader), false);
|
||||
assert.equal(isProjectDataAuthoritative(snapshotProject), false);
|
||||
assert.equal(isProjectDataAuthoritative(locallyWrittenProject), true);
|
||||
assert.equal(isProjectRelayValidated(locallyWrittenProject), false);
|
||||
assert.equal(
|
||||
shouldUseScopedProjectHomeLookup({
|
||||
collectionIsAuthoritative: isProjectCollectionAuthoritative(reader),
|
||||
hasEnumeratedProjectHome: false,
|
||||
isHuddleTranscript: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("successful relay data is authoritative", () => {
|
||||
const client = new QueryClient();
|
||||
const relayProject = markProjectDataAuthoritative({ ...PROJECT }, "relay");
|
||||
client.setQueryData(["projects"], [relayProject]);
|
||||
markProjectCollectionAuthoritative(client);
|
||||
|
||||
assert.equal(isProjectDataAuthoritative(relayProject), true);
|
||||
assert.equal(isProjectRelayValidated(relayProject), true);
|
||||
assert.equal(isProjectCollectionAuthoritative(client), true);
|
||||
assert.equal(
|
||||
shouldUseScopedProjectHomeLookup({
|
||||
collectionIsAuthoritative: isProjectCollectionAuthoritative(client),
|
||||
hasEnumeratedProjectHome: false,
|
||||
isHuddleTranscript: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("equal relay data replaces snapshot objects and preserves provenance", async () => {
|
||||
const writer = new QueryClient();
|
||||
seedProjectSnapshot(writer, { pubkey: OWNER, relayUrl: RELAY });
|
||||
persistProjectSnapshot(writer, [PROJECT]);
|
||||
|
||||
const reader = new QueryClient();
|
||||
seedProjectSnapshot(reader, { pubkey: OWNER, relayUrl: RELAY });
|
||||
const snapshotProject = reader.getQueryData(["projects"])[0];
|
||||
const liveProject = markProjectDataAuthoritative({ ...PROJECT }, "relay");
|
||||
|
||||
await reader.fetchQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: async () => {
|
||||
markProjectCollectionAuthoritative(reader);
|
||||
return [liveProject];
|
||||
},
|
||||
structuralSharing: PROJECT_QUERY_STRUCTURAL_SHARING,
|
||||
});
|
||||
|
||||
const cachedProject = reader.getQueryData(["projects"])[0];
|
||||
assert.notEqual(cachedProject, snapshotProject);
|
||||
assert.equal(cachedProject, liveProject);
|
||||
assert.equal(isProjectRelayValidated(cachedProject), true);
|
||||
assert.equal(isProjectCollectionAuthoritative(reader), true);
|
||||
});
|
||||
233
desktop/src/features/projects/projectSnapshot.ts
Normal file
233
desktop/src/features/projects/projectSnapshot.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
|
||||
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
|
||||
import type { Project } from "./projectModels";
|
||||
|
||||
const STORAGE_KEY_PREFIX = "buzz-projects.v1";
|
||||
export const PROJECTS_QUERY_KEY = ["projects"] as const;
|
||||
/** Project provenance is non-enumerable, so equal relay rows must replace snapshots. */
|
||||
export const PROJECT_QUERY_STRUCTURAL_SHARING = false;
|
||||
const PROJECT_PROVENANCE = Symbol("project-provenance");
|
||||
type ProjectProvenance = "local-write" | "relay";
|
||||
|
||||
type ProjectSnapshotScope = {
|
||||
pubkey: string;
|
||||
relayUrl: string;
|
||||
};
|
||||
|
||||
type StoredProjectSnapshot = {
|
||||
integrity: string;
|
||||
ownerPubkey: string;
|
||||
projects: Project[];
|
||||
updatedAt: number;
|
||||
version: 1;
|
||||
};
|
||||
|
||||
const snapshotScopes = new WeakMap<QueryClient, ProjectSnapshotScope>();
|
||||
const authoritativeProjectCollections = new WeakSet<QueryClient>();
|
||||
|
||||
/** Marks one project's origin without upgrading sibling snapshot rows. */
|
||||
export function markProjectDataAuthoritative<T extends Project>(
|
||||
project: T,
|
||||
provenance: ProjectProvenance,
|
||||
): T {
|
||||
Object.defineProperty(project, PROJECT_PROVENANCE, {
|
||||
configurable: true,
|
||||
value: provenance,
|
||||
});
|
||||
return project;
|
||||
}
|
||||
|
||||
/** Returns whether this exact project came from a live read or local write. */
|
||||
export function isProjectDataAuthoritative(
|
||||
project: Project | null | undefined,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
project &&
|
||||
(project as Project & { [PROJECT_PROVENANCE]?: ProjectProvenance })[
|
||||
PROJECT_PROVENANCE
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns whether relay reads validated this project's repository models. */
|
||||
export function isProjectRelayValidated(
|
||||
project: Project | null | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
(
|
||||
project as
|
||||
| (Project & { [PROJECT_PROVENANCE]?: ProjectProvenance })
|
||||
| null
|
||||
| undefined
|
||||
)?.[PROJECT_PROVENANCE] === "relay"
|
||||
);
|
||||
}
|
||||
|
||||
/** Copies non-serialized provenance when replacing one cached project object. */
|
||||
export function inheritProjectDataProvenance<T extends Project>(
|
||||
source: Project,
|
||||
replacement: T,
|
||||
): T {
|
||||
const provenance = (
|
||||
source as Project & { [PROJECT_PROVENANCE]?: ProjectProvenance }
|
||||
)[PROJECT_PROVENANCE];
|
||||
return provenance
|
||||
? markProjectDataAuthoritative(replacement, provenance)
|
||||
: replacement;
|
||||
}
|
||||
|
||||
/** Records that exhaustive relay enumeration completed for this query client. */
|
||||
export function markProjectCollectionAuthoritative(
|
||||
queryClient: QueryClient,
|
||||
): void {
|
||||
authoritativeProjectCollections.add(queryClient);
|
||||
}
|
||||
|
||||
/** Returns whether exhaustive relay enumeration completed for this client. */
|
||||
export function isProjectCollectionAuthoritative(
|
||||
queryClient: QueryClient,
|
||||
): boolean {
|
||||
return authoritativeProjectCollections.has(queryClient);
|
||||
}
|
||||
|
||||
/** Keeps the active-channel fast path live while only a snapshot is present. */
|
||||
export function shouldUseScopedProjectHomeLookup({
|
||||
collectionIsAuthoritative,
|
||||
hasEnumeratedProjectHome,
|
||||
isHuddleTranscript,
|
||||
}: {
|
||||
collectionIsAuthoritative: boolean;
|
||||
hasEnumeratedProjectHome: boolean;
|
||||
isHuddleTranscript: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
!isHuddleTranscript &&
|
||||
!hasEnumeratedProjectHome &&
|
||||
!collectionIsAuthoritative
|
||||
);
|
||||
}
|
||||
|
||||
function projectSnapshotRelayPrefix(relayUrl: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}:`;
|
||||
}
|
||||
|
||||
export function projectSnapshotKey(
|
||||
relayUrl: string,
|
||||
ownerPubkey: string,
|
||||
): string {
|
||||
return `${projectSnapshotRelayPrefix(relayUrl)}${ownerPubkey.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function snapshotIntegrity(ownerPubkey: string, projects: Project[]): string {
|
||||
const value = JSON.stringify([ownerPubkey.toLowerCase(), projects]);
|
||||
let result = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
result ^= value.charCodeAt(index);
|
||||
result = Math.imul(result, 0x01000193);
|
||||
}
|
||||
return (result >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
function isProject(value: unknown): value is Project {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const project = value as Partial<Project>;
|
||||
return (
|
||||
typeof project.id === "string" &&
|
||||
typeof project.owner === "string" &&
|
||||
typeof project.projectAddress === "string" &&
|
||||
(typeof project.projectChannelId === "string" ||
|
||||
project.projectChannelId === null) &&
|
||||
Array.isArray(project.repositoryAddresses) &&
|
||||
Array.isArray(project.repositories) &&
|
||||
typeof project.legacy === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
function parseProjectSnapshot(
|
||||
value: unknown,
|
||||
ownerPubkey: string,
|
||||
): Project[] | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
const snapshot = value as Partial<StoredProjectSnapshot>;
|
||||
if (
|
||||
snapshot.version !== 1 ||
|
||||
snapshot.ownerPubkey?.toLowerCase() !== ownerPubkey.toLowerCase() ||
|
||||
!Array.isArray(snapshot.projects) ||
|
||||
!snapshot.projects.every(isProject) ||
|
||||
typeof snapshot.integrity !== "string" ||
|
||||
snapshot.integrity !== snapshotIntegrity(ownerPubkey, snapshot.projects)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.projects;
|
||||
}
|
||||
|
||||
export function readProjectSnapshot(
|
||||
relayUrl: string,
|
||||
ownerPubkey: string,
|
||||
): Project[] | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(
|
||||
projectSnapshotKey(relayUrl, ownerPubkey),
|
||||
);
|
||||
return raw ? parseProjectSnapshot(JSON.parse(raw), ownerPubkey) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the last fully validated project collection into a community's fresh
|
||||
* query client. Timestamp zero keeps it stale so the relay revalidates it.
|
||||
*/
|
||||
export function seedProjectSnapshot(
|
||||
queryClient: QueryClient,
|
||||
scope: ProjectSnapshotScope,
|
||||
): void {
|
||||
snapshotScopes.set(queryClient, scope);
|
||||
const projects = readProjectSnapshot(scope.relayUrl, scope.pubkey);
|
||||
if (projects) {
|
||||
queryClient.setQueryData(PROJECTS_QUERY_KEY, projects, { updatedAt: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Persists a successful complete enumeration for the current community. */
|
||||
export function persistProjectSnapshot(
|
||||
queryClient: QueryClient,
|
||||
projects: Project[],
|
||||
): void {
|
||||
const scope = snapshotScopes.get(queryClient);
|
||||
if (!scope) return;
|
||||
try {
|
||||
const snapshot: StoredProjectSnapshot = {
|
||||
integrity: snapshotIntegrity(scope.pubkey, projects),
|
||||
ownerPubkey: scope.pubkey.toLowerCase(),
|
||||
projects,
|
||||
updatedAt: Date.now(),
|
||||
version: 1,
|
||||
};
|
||||
setLocalStorageItemWithRecovery(
|
||||
projectSnapshotKey(scope.relayUrl, scope.pubkey),
|
||||
JSON.stringify(snapshot),
|
||||
);
|
||||
} catch {
|
||||
// Snapshot persistence is optional; live relay data remains authoritative.
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes every identity's project snapshot for a deleted community. */
|
||||
export function removeProjectSnapshotForRelay(relayUrl: string): void {
|
||||
try {
|
||||
const prefix = projectSnapshotRelayPrefix(relayUrl);
|
||||
const keys: string[] = [];
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index);
|
||||
if (key?.startsWith(prefix)) keys.push(key);
|
||||
}
|
||||
for (const key of keys) window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Storage access failures are non-fatal.
|
||||
}
|
||||
}
|
||||
@@ -85,12 +85,14 @@ function ProjectHomeHeaderToggle({
|
||||
}
|
||||
|
||||
export function ProjectChannelHome({
|
||||
allowRepositoryHealing,
|
||||
autoSendDraftKey,
|
||||
project,
|
||||
projects,
|
||||
targetMessageEvents = EMPTY_TARGET_MESSAGE_EVENTS,
|
||||
targetMessageId,
|
||||
}: {
|
||||
allowRepositoryHealing: boolean;
|
||||
autoSendDraftKey?: string | null;
|
||||
project: Project;
|
||||
projects: Project[];
|
||||
@@ -194,7 +196,11 @@ export function ProjectChannelHome({
|
||||
},
|
||||
[],
|
||||
);
|
||||
useHealProjectHomeRepositories(project, identityQuery.data?.pubkey);
|
||||
useHealProjectHomeRepositories(
|
||||
project,
|
||||
allowRepositoryHealing,
|
||||
identityQuery.data?.pubkey,
|
||||
);
|
||||
const handleOpenCommit = React.useCallback(
|
||||
(commitHash: string) => {
|
||||
if (!workspaceRepository) return;
|
||||
@@ -252,6 +258,7 @@ export function ProjectChannelHome({
|
||||
)}
|
||||
data-project-context-detached={summaryVisible ? "true" : undefined}
|
||||
data-project-detail-screen
|
||||
data-repository-healing-enabled={allowRepositoryHealing}
|
||||
data-testid="project-channel-home"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import { wantsProjectRepositorySurface } from "@/features/projects/lib/projectDetailSearch";
|
||||
import { hasAuthoritativeHomeBinding } from "@/features/projects/lib/projectHomeChannel";
|
||||
import { selectProjectRepository } from "@/features/projects/projectModels";
|
||||
import { isProjectRelayValidated } from "@/features/projects/projectSnapshot";
|
||||
import { ProjectSelectionProvider } from "@/features/projects/lib/useProjectSelection";
|
||||
import { useMemberChannelIds } from "@/features/projects/useRepositoryAccess";
|
||||
import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds";
|
||||
@@ -693,6 +694,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
if (showChannelHome) {
|
||||
return (
|
||||
<ProjectChannelHome
|
||||
allowRepositoryHealing={isProjectRelayValidated(project)}
|
||||
project={project}
|
||||
projects={projectsQuery.data ?? [project]}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { isUnsupportedProjectKindError } from "@/features/projects/projectCreati
|
||||
import { buildProjectRelatedChannelPatchTemplate } from "@/features/projects/projectChannelCreation";
|
||||
import { addRelatedChannelToProject } from "@/features/projects/projectModels";
|
||||
import { publishOwnedAgentProjectAnnouncements } from "@/features/projects/projectOwnerControl";
|
||||
import { markProjectDataAuthoritative } from "@/features/projects/projectSnapshot";
|
||||
import { publishProjectOwnerAnnouncement } from "@/shared/api/projectGit";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { deleteChannel as deleteChannelApi } from "@/shared/api/tauriChannels";
|
||||
@@ -183,6 +184,7 @@ export function useAddProjectChannelMutation() {
|
||||
createChannel: createChannelMutation.mutateAsync,
|
||||
}),
|
||||
onSuccess: ({ channel, project }) => {
|
||||
markProjectDataAuthoritative(project, "local-write");
|
||||
queryClient.setQueryData<Project[]>(projectsQueryKey, (current = []) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === project.id ? project : candidate,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
addRepositoryToProject,
|
||||
eventToRepository,
|
||||
} from "@/features/projects/projectModels";
|
||||
import { markProjectDataAuthoritative } from "@/features/projects/projectSnapshot";
|
||||
import { publishProjectOwnerAnnouncement } from "@/shared/api/projectGit";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
@@ -315,6 +316,7 @@ export function useAddProjectRepositoryMutation() {
|
||||
mutationFn: (input: AddProjectRepositoryInput) =>
|
||||
addProjectRepository(input),
|
||||
onSuccess: ({ previousProjectId, project }) => {
|
||||
markProjectDataAuthoritative(project, "local-write");
|
||||
if (previousProjectId !== project.id) {
|
||||
queryClient.removeQueries({
|
||||
exact: true,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { publishOwnedAgentProjectAnnouncements } from "@/features/projects/projectOwnerControl";
|
||||
import { addRepositoryToProject } from "@/features/projects/projectModels";
|
||||
import { buildProjectPatchTemplate } from "@/features/projects/projectRepositoryCreation";
|
||||
import { markProjectDataAuthoritative } from "@/features/projects/projectSnapshot";
|
||||
import { publishProjectOwnerAnnouncement } from "@/shared/api/projectGit";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { KIND_PROJECT_ANNOUNCEMENT } from "@/shared/constants/kinds";
|
||||
@@ -109,6 +110,7 @@ export function useAttachProjectRepositoryMutation() {
|
||||
return useMutation({
|
||||
mutationFn: attachProjectRepository,
|
||||
onSuccess: ({ previousProjectId, project }) => {
|
||||
markProjectDataAuthoritative(project, "local-write");
|
||||
if (previousProjectId !== project.id) {
|
||||
queryClient.removeQueries({
|
||||
exact: true,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/features/projects/hooks";
|
||||
import { eventToRepository } from "@/features/projects/projectModels";
|
||||
import { buildRepositoryChannelBindingTemplate } from "@/features/projects/projectRepositoryCreation";
|
||||
import { inheritProjectDataProvenance } from "@/features/projects/projectSnapshot";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { signRelayEvent } from "@/shared/api/tauri";
|
||||
import { getIdentity } from "@/shared/api/tauriIdentity";
|
||||
@@ -53,14 +54,24 @@ export function useBindProjectRepositoryChannelMutation() {
|
||||
mutationFn: bindProjectRepositoryChannel,
|
||||
onSuccess: (repository) => {
|
||||
queryClient.setQueryData<Project[]>(projectsQueryKey, (current = []) =>
|
||||
current.map((project) => ({
|
||||
...project,
|
||||
repositories: project.repositories.map((candidate) =>
|
||||
candidate.repoAddress === repository.repoAddress
|
||||
? repository
|
||||
: candidate,
|
||||
),
|
||||
})),
|
||||
current.map((project) => {
|
||||
if (
|
||||
!project.repositories.some(
|
||||
(candidate) => candidate.repoAddress === repository.repoAddress,
|
||||
)
|
||||
) {
|
||||
return project;
|
||||
}
|
||||
const updatedProject = {
|
||||
...project,
|
||||
repositories: project.repositories.map((candidate) =>
|
||||
candidate.repoAddress === repository.repoAddress
|
||||
? repository
|
||||
: candidate,
|
||||
),
|
||||
};
|
||||
return inheritProjectDataProvenance(project, updatedProject);
|
||||
}),
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
applyProjectHomeCanvas,
|
||||
PROJECT_HOME_TEMPLATE_ID,
|
||||
} from "@/features/projects/lib/projectHomeTemplate";
|
||||
import { markProjectDataAuthoritative } from "@/features/projects/projectSnapshot";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl";
|
||||
|
||||
@@ -37,6 +38,7 @@ export function useCreateProjectMutation() {
|
||||
mutationFn: (input: CreateProjectInput) =>
|
||||
createProject(input, resumeRef.current),
|
||||
onSuccess: async ({ channel, project }, input) => {
|
||||
markProjectDataAuthoritative(project, "local-write");
|
||||
addProjectToSidebar(
|
||||
project.projectAddress,
|
||||
getCachedRelayOrigin(),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { canHealProjectHomeRepositories } from "./useHealProjectHomeRepositories.ts";
|
||||
|
||||
const OWNER = "a".repeat(64);
|
||||
const PROJECT = {
|
||||
owner: OWNER,
|
||||
repositories: [{ id: "repository" }],
|
||||
};
|
||||
|
||||
test("snapshot-derived projects cannot trigger repository healing", () => {
|
||||
assert.equal(
|
||||
canHealProjectHomeRepositories({
|
||||
identityPubkey: OWNER,
|
||||
project: PROJECT,
|
||||
projectDataIsAuthoritative: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("authoritative owner projects with repositories can heal", () => {
|
||||
assert.equal(
|
||||
canHealProjectHomeRepositories({
|
||||
identityPubkey: OWNER,
|
||||
project: PROJECT,
|
||||
projectDataIsAuthoritative: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("repository healing still rejects a non-owner identity", () => {
|
||||
assert.equal(
|
||||
canHealProjectHomeRepositories({
|
||||
identityPubkey: "b".repeat(64),
|
||||
project: PROJECT,
|
||||
projectDataIsAuthoritative: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -7,6 +7,24 @@ import { relayClient } from "@/shared/api/relayClient";
|
||||
import { KIND_PROJECT_ANNOUNCEMENT } from "@/shared/constants/kinds";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
/** Allows automatic healing only from relay-validated owner project data. */
|
||||
export function canHealProjectHomeRepositories({
|
||||
identityPubkey,
|
||||
project,
|
||||
projectDataIsAuthoritative,
|
||||
}: {
|
||||
identityPubkey?: string;
|
||||
project: Pick<Project, "owner" | "repositories">;
|
||||
projectDataIsAuthoritative: boolean;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
projectDataIsAuthoritative &&
|
||||
identityPubkey &&
|
||||
normalizePubkey(identityPubkey) === normalizePubkey(project.owner) &&
|
||||
project.repositories.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* When the signed-in user owns this project, bind absorbed home-channel
|
||||
* repositories onto the `kind:30621` so Overview, other clients, and NIP-MP
|
||||
@@ -15,6 +33,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
*/
|
||||
export function useHealProjectHomeRepositories(
|
||||
project: Project,
|
||||
projectDataIsAuthoritative: boolean,
|
||||
identityPubkey?: string,
|
||||
) {
|
||||
const attachMutation = useAttachProjectRepositoryMutation();
|
||||
@@ -22,11 +41,15 @@ export function useHealProjectHomeRepositories(
|
||||
const mutateAsync = attachMutation.mutateAsync;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!identityPubkey) return;
|
||||
if (normalizePubkey(identityPubkey) !== normalizePubkey(project.owner)) {
|
||||
if (
|
||||
!canHealProjectHomeRepositories({
|
||||
identityPubkey,
|
||||
project,
|
||||
projectDataIsAuthoritative,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (project.repositories.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
@@ -58,5 +81,5 @@ export function useHealProjectHomeRepositories(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [identityPubkey, mutateAsync, project]);
|
||||
}, [identityPubkey, mutateAsync, project, projectDataIsAuthoritative]);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ test("relay invalidation separates relay project queries from local repo work",
|
||||
["project", "project-1", "pull-requests"],
|
||||
["projects", "issues", ["project-1"]],
|
||||
["projects", "activity-summaries", ["addr-1"]],
|
||||
["projects", "home-channel", "channel-1"],
|
||||
// Work items fan out over the relay and are fresh for two minutes;
|
||||
// reconnect auto-heal must be able to repair a partial result.
|
||||
["projects", "work-items", ["project-1"], ["addr-1"]],
|
||||
|
||||
@@ -37,6 +37,7 @@ const RELAY_QUERY_ROOTS = new Set<string>([
|
||||
|
||||
const RELAY_PROJECT_QUERY_PARTS = new Set<string>([
|
||||
"activity-summaries",
|
||||
"home-channel",
|
||||
"issues",
|
||||
"pull-requests",
|
||||
// Fresh for two minutes (PROJECT_WORK_ITEMS_STALE_TIME_MS) and tolerant of
|
||||
|
||||
@@ -34,10 +34,11 @@ function install(ls) {
|
||||
}
|
||||
|
||||
test("startup recovery removes disposable caches but preserves user state", () => {
|
||||
const ls = makeQuotaLocalStorage({ maxEntries: 6 });
|
||||
const ls = makeQuotaLocalStorage({ maxEntries: 7 });
|
||||
install(ls);
|
||||
ls.store.set("buzz-channel-messages.v1:relay:chan", "big");
|
||||
ls.store.set("buzz-channels.v1:relay", "big");
|
||||
ls.store.set("buzz-projects.v1:relay:owner", "big");
|
||||
ls.store.set("buzz-timeline-skeleton-shape.v1:chan", "small");
|
||||
ls.store.set("buzz-sidebar-skeleton-shape.v1:community:user", "small");
|
||||
ls.store.set("buzz-user-labels.v1:relay", "small");
|
||||
@@ -47,6 +48,7 @@ test("startup recovery removes disposable caches but preserves user state", () =
|
||||
|
||||
assert.equal(ls.getItem("buzz-channel-messages.v1:relay:chan"), null);
|
||||
assert.equal(ls.getItem("buzz-channels.v1:relay"), null);
|
||||
assert.equal(ls.getItem("buzz-projects.v1:relay:owner"), null);
|
||||
assert.equal(ls.getItem("buzz-timeline-skeleton-shape.v1:chan"), null);
|
||||
assert.equal(
|
||||
ls.getItem("buzz-sidebar-skeleton-shape.v1:community:user"),
|
||||
@@ -163,7 +165,7 @@ test("global cache byte budget spans relays and preserves durable state", () =>
|
||||
test("rejects a single cache entry larger than the global byte budget", () => {
|
||||
const ls = makeQuotaLocalStorage({ maxEntries: 10 });
|
||||
install(ls);
|
||||
const key = "buzz-channel-messages.v1:relay:oversized";
|
||||
const key = "buzz-projects.v1:relay:oversized";
|
||||
ls.store.set(key, "previous snapshot");
|
||||
|
||||
assert.equal(
|
||||
@@ -183,15 +185,35 @@ test("writes normally when under quota", () => {
|
||||
test("evicts pure caches and retries on quota failure", () => {
|
||||
const ls = makeQuotaLocalStorage({ maxEntries: 2 });
|
||||
install(ls);
|
||||
ls.store.set("buzz-channel-messages.v1:relay:chan", "big");
|
||||
ls.store.set("buzz-projects.v1:relay:owner", "big");
|
||||
ls.store.set("buzz-channels.v1:relay", "big");
|
||||
|
||||
assert.equal(setLocalStorageItemWithRecovery("k", "v"), true);
|
||||
assert.equal(ls.getItem("k"), "v");
|
||||
assert.equal(ls.getItem("buzz-channel-messages.v1:relay:chan"), null);
|
||||
assert.equal(ls.getItem("buzz-projects.v1:relay:owner"), null);
|
||||
assert.equal(ls.getItem("buzz-channels.v1:relay"), null);
|
||||
});
|
||||
|
||||
test("project snapshots participate in global LRU budgeting", () => {
|
||||
const ls = makeQuotaLocalStorage({ maxEntries: 20 });
|
||||
install(ls);
|
||||
ls.store.set("buzz-communities", "keep");
|
||||
const snapshot = (updatedAt) =>
|
||||
JSON.stringify({ updatedAt, payload: "x".repeat(400_000) });
|
||||
const projectKey = "buzz-projects.v1:relay:owner";
|
||||
const newerKey = "buzz-channels.v1:relay:newer";
|
||||
const newestKey = "buzz-channel-messages.v1:relay:newest";
|
||||
|
||||
assert.equal(setLocalStorageItemWithRecovery(projectKey, snapshot(1)), true);
|
||||
assert.equal(setLocalStorageItemWithRecovery(newerKey, snapshot(2)), true);
|
||||
assert.equal(setLocalStorageItemWithRecovery(newestKey, snapshot(3)), true);
|
||||
|
||||
assert.equal(ls.getItem(projectKey), null);
|
||||
assert.notEqual(ls.getItem(newerKey), null);
|
||||
assert.notEqual(ls.getItem(newestKey), null);
|
||||
assert.equal(ls.getItem("buzz-communities"), "keep");
|
||||
});
|
||||
|
||||
test("returns false when eviction frees nothing", () => {
|
||||
const ls = makeQuotaLocalStorage({ maxEntries: 2 });
|
||||
install(ls);
|
||||
|
||||
@@ -11,6 +11,7 @@ const PURE_CACHE_KEY_PREFIXES = [
|
||||
"buzz-channel-messages.v1:",
|
||||
"buzz-channels.v1:",
|
||||
"buzz-observed-unread.v1:",
|
||||
"buzz-projects.v1:",
|
||||
"buzz-sidebar-skeleton-shape.v1:",
|
||||
"buzz-timeline-skeleton-shape.v1:",
|
||||
"buzz-user-labels.v1:",
|
||||
|
||||
@@ -1053,6 +1053,7 @@ type MockSubscription = {
|
||||
|
||||
type MockFilter = {
|
||||
"#a"?: string[];
|
||||
"#buzz-channel"?: string[];
|
||||
"#d"?: string[];
|
||||
"#e"?: string[];
|
||||
"#h"?: string[];
|
||||
@@ -1256,6 +1257,8 @@ declare global {
|
||||
}>;
|
||||
/** Omits kind 30621 seeds while retaining standalone kind 30617 repositories. */
|
||||
__BUZZ_E2E_REPOSITORY_ONLY_PROJECTS__?: boolean;
|
||||
/** Leaves broad project enumeration pending while scoped project queries remain available. */
|
||||
__BUZZ_E2E_DEFER_FULL_PROJECT_QUERIES__?: boolean;
|
||||
/** Project-scoped events accepted by the mock relay. */
|
||||
__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?: Array<{
|
||||
content: string;
|
||||
@@ -10490,6 +10493,17 @@ function sendToMockSocket(args: {
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
window.__BUZZ_E2E_DEFER_FULL_PROJECT_QUERIES__ &&
|
||||
!filter.authors &&
|
||||
!filter["#a"] &&
|
||||
!filter["#buzz-channel"] &&
|
||||
!filter["#d"] &&
|
||||
!filter["#e"] &&
|
||||
!filter.ids
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for (const event of filterMockProjectEvents(filter)) {
|
||||
sendWsText(socket.handler, ["EVENT", subId, event]);
|
||||
}
|
||||
|
||||
171
desktop/tests/e2e/project-cold-start.spec.ts
Normal file
171
desktop/tests/e2e/project-cold-start.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const PROJECT_HOME_CHANNEL_ID = "cf63feec-21bb-5bf0-a2f8-0e4c3de8ec73";
|
||||
|
||||
async function enableProjectsFeature(page: import("@playwright/test").Page) {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem(
|
||||
"buzz-feature-overrides-v1",
|
||||
JSON.stringify({ projects: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForProjectSnapshot(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
Object.keys(window.localStorage).some((key) =>
|
||||
key.startsWith("buzz-projects.v1:"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function mutateProjectCache(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
"__BUZZ_E2E_QUERY_CLIENT__" in window &&
|
||||
Boolean(window.__BUZZ_E2E_QUERY_CLIENT__),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.evaluate(() => {
|
||||
const queryClient = (
|
||||
window as typeof window & {
|
||||
__BUZZ_E2E_QUERY_CLIENT__?: {
|
||||
setQueryData: (
|
||||
key: readonly string[],
|
||||
updater: (current: unknown) => unknown,
|
||||
) => void;
|
||||
};
|
||||
}
|
||||
).__BUZZ_E2E_QUERY_CLIENT__;
|
||||
if (!queryClient) throw new Error("E2E query client is unavailable.");
|
||||
queryClient.setQueryData(["projects"], (current) =>
|
||||
Array.isArray(current) ? [...current] : current,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForProjectEnumeration(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const queryClient = window.__BUZZ_E2E_QUERY_CLIENT__;
|
||||
const state = queryClient?.getQueryState(["projects"]);
|
||||
return Boolean(
|
||||
state && state.fetchStatus === "idle" && state.dataUpdatedAt > 0,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("snapshot project home cannot publish repository healing", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enableProjectsFeature(page);
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await waitForProjectSnapshot(page);
|
||||
await page.getByTestId("channel-buzz").click();
|
||||
await expect(page.getByTestId("project-home-context-panel")).toBeVisible();
|
||||
|
||||
await page.addInitScript(() => {
|
||||
window.__BUZZ_E2E_DEFER_FULL_PROJECT_QUERIES__ = true;
|
||||
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__ = [];
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await mutateProjectCache(page);
|
||||
await page.getByTestId("channel-buzz").click();
|
||||
|
||||
await expect(page.getByTestId("project-home-context-panel")).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
const projectPublications = await page.evaluate(
|
||||
() =>
|
||||
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?.filter(
|
||||
(event) =>
|
||||
event.kind === 30621 &&
|
||||
event.tags.some((tag) => tag[0] === "d" && tag[1] === "buzz"),
|
||||
) ?? [],
|
||||
);
|
||||
expect(projectPublications).toEqual([]);
|
||||
});
|
||||
|
||||
test("stale non-matching snapshot uses the scoped project-home lookup", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enableProjectsFeature(page);
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await waitForProjectSnapshot(page);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const key = Object.keys(window.localStorage).find((candidate) =>
|
||||
candidate.startsWith("buzz-projects.v1:"),
|
||||
);
|
||||
if (!key) throw new Error("Project snapshot was not persisted.");
|
||||
const snapshot = JSON.parse(window.localStorage.getItem(key) ?? "{}");
|
||||
snapshot.projects = snapshot.projects.filter(
|
||||
(project) => project.dtag !== "buzz",
|
||||
);
|
||||
const value = JSON.stringify([
|
||||
snapshot.ownerPubkey.toLowerCase(),
|
||||
snapshot.projects,
|
||||
]);
|
||||
let integrity = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
integrity ^= value.charCodeAt(index);
|
||||
integrity = Math.imul(integrity, 0x01000193);
|
||||
}
|
||||
snapshot.integrity = (integrity >>> 0).toString(16).padStart(8, "0");
|
||||
window.localStorage.setItem(key, JSON.stringify(snapshot));
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
window.__BUZZ_E2E_DEFER_FULL_PROJECT_QUERIES__ = true;
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await mutateProjectCache(page);
|
||||
await page.getByTestId("channel-buzz").click();
|
||||
|
||||
await expect(page.getByTestId("project-home-context-panel")).toBeVisible();
|
||||
const usedScopedLookup = await page.evaluate((channelId) => {
|
||||
return window.__BUZZ_E2E_PROJECT_QUERY_FILTERS__?.some((filter) =>
|
||||
filter["#buzz-channel"]?.includes(channelId),
|
||||
);
|
||||
}, PROJECT_HOME_CHANNEL_ID);
|
||||
expect(usedScopedLookup).toBe(true);
|
||||
});
|
||||
|
||||
test("equal live project data enables healing after snapshot reconciliation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
Date.now = () => 1_787_872_972_113;
|
||||
});
|
||||
await enableProjectsFeature(page);
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await waitForProjectSnapshot(page);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await waitForProjectEnumeration(page);
|
||||
await page.getByTestId("channel-buzz").click();
|
||||
|
||||
await expect(page.getByTestId("project-channel-home")).toHaveAttribute(
|
||||
"data-repository-healing-enabled",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user