diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 2894a078d0d..ee7d4ec9bf0 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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", diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index bdb26c1930d..da0fbf65c49 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -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; diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index c38d7f7d463..50371bc369f 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -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 ( clearChannelHeadCache({ diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index b3d0526514c..cc71515a9d0 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -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 { - // 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, }); } diff --git a/desktop/src/features/projects/lib/projectHomeChannel.ts b/desktop/src/features/projects/lib/projectHomeChannel.ts index 5389aa22fda..f5b68c558fb 100644 --- a/desktop/src/features/projects/lib/projectHomeChannel.ts +++ b/desktop/src/features/projects/lib/projectHomeChannel.ts @@ -1,64 +1,12 @@ import { useProjectsQuery } from "@/features/projects/hooks"; -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; - 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, -): boolean { - if (!channelId) return false; - return projects.some( - (project) => - project.projectChannelId === channelId && - hasAuthoritativeHomeBinding(project), - ); -} +import { isProjectHomeChannel } from "./projectHomeSelection"; + +export { + findProjectHomeByChannelId, + hasAuthoritativeHomeBinding, + isProjectHomeChannel, + type ProjectHomeCandidate, +} from "./projectHomeSelection"; export function useIsProjectHomeChannel(channelId: string | null | undefined) { const projectsQuery = useProjectsQuery(); diff --git a/desktop/src/features/projects/lib/projectHomeSelection.ts b/desktop/src/features/projects/lib/projectHomeSelection.ts new file mode 100644 index 00000000000..caea21230f6 --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeSelection.ts @@ -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; + 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, +): boolean { + if (!channelId) return false; + return projects.some( + (project) => + project.projectChannelId === channelId && + hasAuthoritativeHomeBinding(project), + ); +} diff --git a/desktop/src/features/projects/projectEnumeration.test.mjs b/desktop/src/features/projects/projectEnumeration.test.mjs index 49cec525162..9f9fe26b256 100644 --- a/desktop/src/features/projects/projectEnumeration.test.mjs +++ b/desktop/src/features/projects/projectEnumeration.test.mjs @@ -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), diff --git a/desktop/src/features/projects/projectEnumeration.ts b/desktop/src/features/projects/projectEnumeration.ts index 6a1929fc2c6..109ccc2d82d 100644 --- a/desktop/src/features/projects/projectEnumeration.ts +++ b/desktop/src/features/projects/projectEnumeration.ts @@ -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; + viewerPubkey?: string | null; + } = {}, +): Promise { + const projects = await buildProjectsFromFetcher( + (kinds, extraFilter) => + fetchExhaustively( + kinds, + kinds.includes(KIND_DELETION) + ? extraFilter + : { ...extraFilter, "#buzz-channel": [channelId] }, + ), + options, + ); + return findProjectHomeByChannelId(channelId, projects); +} diff --git a/desktop/src/features/projects/projectFetch.ts b/desktop/src/features/projects/projectFetch.ts new file mode 100644 index 00000000000..149d62de4c9 --- /dev/null +++ b/desktop/src/features/projects/projectFetch.ts @@ -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 { + // 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 { + 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; +} diff --git a/desktop/src/features/projects/projectSnapshot.test.mjs b/desktop/src/features/projects/projectSnapshot.test.mjs new file mode 100644 index 00000000000..0066c190457 --- /dev/null +++ b/desktop/src/features/projects/projectSnapshot.test.mjs @@ -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); +}); diff --git a/desktop/src/features/projects/projectSnapshot.ts b/desktop/src/features/projects/projectSnapshot.ts new file mode 100644 index 00000000000..543e6cb761d --- /dev/null +++ b/desktop/src/features/projects/projectSnapshot.ts @@ -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(); +const authoritativeProjectCollections = new WeakSet(); + +/** Marks one project's origin without upgrading sibling snapshot rows. */ +export function markProjectDataAuthoritative( + 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( + 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; + 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; + 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. + } +} diff --git a/desktop/src/features/projects/ui/ProjectChannelHome.tsx b/desktop/src/features/projects/ui/ProjectChannelHome.tsx index d7b0d766fad..dc526b1f0ef 100644 --- a/desktop/src/features/projects/ui/ProjectChannelHome.tsx +++ b/desktop/src/features/projects/ui/ProjectChannelHome.tsx @@ -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" >
diff --git a/desktop/src/features/projects/useAddProjectChannel.ts b/desktop/src/features/projects/useAddProjectChannel.ts index a5ae11cb482..1ef50bf9340 100644 --- a/desktop/src/features/projects/useAddProjectChannel.ts +++ b/desktop/src/features/projects/useAddProjectChannel.ts @@ -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(projectsQueryKey, (current = []) => current.map((candidate) => candidate.id === project.id ? project : candidate, diff --git a/desktop/src/features/projects/useAddProjectRepository.ts b/desktop/src/features/projects/useAddProjectRepository.ts index ad4d5954dfc..fc68240f552 100644 --- a/desktop/src/features/projects/useAddProjectRepository.ts +++ b/desktop/src/features/projects/useAddProjectRepository.ts @@ -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, diff --git a/desktop/src/features/projects/useAttachProjectRepository.ts b/desktop/src/features/projects/useAttachProjectRepository.ts index 07376c15e27..c43f215762c 100644 --- a/desktop/src/features/projects/useAttachProjectRepository.ts +++ b/desktop/src/features/projects/useAttachProjectRepository.ts @@ -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, diff --git a/desktop/src/features/projects/useBindProjectRepositoryChannel.ts b/desktop/src/features/projects/useBindProjectRepositoryChannel.ts index 8b6f1d2a1dd..32c731e6c20 100644 --- a/desktop/src/features/projects/useBindProjectRepositoryChannel.ts +++ b/desktop/src/features/projects/useBindProjectRepositoryChannel.ts @@ -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(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 }); }, diff --git a/desktop/src/features/projects/useCreateProject.ts b/desktop/src/features/projects/useCreateProject.ts index eb1ea080706..385c5827ed7 100644 --- a/desktop/src/features/projects/useCreateProject.ts +++ b/desktop/src/features/projects/useCreateProject.ts @@ -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(), diff --git a/desktop/src/features/projects/useHealProjectHomeRepositories.test.mjs b/desktop/src/features/projects/useHealProjectHomeRepositories.test.mjs new file mode 100644 index 00000000000..0a8f95f117e --- /dev/null +++ b/desktop/src/features/projects/useHealProjectHomeRepositories.test.mjs @@ -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, + ); +}); diff --git a/desktop/src/features/projects/useHealProjectHomeRepositories.ts b/desktop/src/features/projects/useHealProjectHomeRepositories.ts index 747f60f8347..5e5f3f9a509 100644 --- a/desktop/src/features/projects/useHealProjectHomeRepositories.ts +++ b/desktop/src/features/projects/useHealProjectHomeRepositories.ts @@ -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; + 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]); } diff --git a/desktop/src/shared/api/relayQueryInvalidation.test.mjs b/desktop/src/shared/api/relayQueryInvalidation.test.mjs index 059d2f4b7fb..7d0fd3098b5 100644 --- a/desktop/src/shared/api/relayQueryInvalidation.test.mjs +++ b/desktop/src/shared/api/relayQueryInvalidation.test.mjs @@ -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"]], diff --git a/desktop/src/shared/api/relayQueryInvalidation.ts b/desktop/src/shared/api/relayQueryInvalidation.ts index 6892c54a348..b42d9f39612 100644 --- a/desktop/src/shared/api/relayQueryInvalidation.ts +++ b/desktop/src/shared/api/relayQueryInvalidation.ts @@ -37,6 +37,7 @@ const RELAY_QUERY_ROOTS = new Set([ const RELAY_PROJECT_QUERY_PARTS = new Set([ "activity-summaries", + "home-channel", "issues", "pull-requests", // Fresh for two minutes (PROJECT_WORK_ITEMS_STALE_TIME_MS) and tolerant of diff --git a/desktop/src/shared/lib/localStorageQuota.test.mjs b/desktop/src/shared/lib/localStorageQuota.test.mjs index 9b5061e5555..41bd52c1a1e 100644 --- a/desktop/src/shared/lib/localStorageQuota.test.mjs +++ b/desktop/src/shared/lib/localStorageQuota.test.mjs @@ -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); diff --git a/desktop/src/shared/lib/localStorageQuota.ts b/desktop/src/shared/lib/localStorageQuota.ts index 189ff09fbb1..68f6b50aa39 100644 --- a/desktop/src/shared/lib/localStorageQuota.ts +++ b/desktop/src/shared/lib/localStorageQuota.ts @@ -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:", diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index bf16dd3b00a..c620c577648 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -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]); } diff --git a/desktop/tests/e2e/project-cold-start.spec.ts b/desktop/tests/e2e/project-cold-start.spec.ts new file mode 100644 index 00000000000..8fcfb0eafc0 --- /dev/null +++ b/desktop/tests/e2e/project-cold-start.spec.ts @@ -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 { + 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 { + 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 { + 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", + ); +});