Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
27 changes: 25 additions & 2 deletions desktop/src/app/routes/ChannelRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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();
Expand All @@ -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[]
>(() => {
Expand Down Expand Up @@ -276,6 +298,7 @@ export function ChannelRouteScreen({
if (projectHome && !isHuddleTranscript) {
return (
<ProjectChannelHome
allowRepositoryHealing={isProjectRelayValidated(projectHome)}
autoSendDraftKey={autoSendDraftKey}
project={projectHome}
projects={projectsQuery.data ?? [projectHome]}
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/communities/useCommunities.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -235,6 +236,7 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
removeSelfProfileCachesForRelay(removed.relayUrl);
removeUserLabelCacheForRelay(removed.relayUrl);
removeChannelSnapshotForRelay(removed.relayUrl);
removeProjectSnapshotForRelay(removed.relayUrl);
void getIdentity()
.then((identity) =>
clearChannelHeadCache({
Expand Down
81 changes: 35 additions & 46 deletions desktop/src/features/projects/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 }>;
Expand Down Expand Up @@ -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`.
*
Expand All @@ -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"] = [];
Expand Down Expand Up @@ -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,
});
}

Expand Down
68 changes: 8 additions & 60 deletions desktop/src/features/projects/lib/projectHomeChannel.ts
Original file line number Diff line number Diff line change
@@ -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<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),
);
}
import { isProjectHomeChannel } from "./projectHomeSelection";

export {
findProjectHomeByChannelId,
hasAuthoritativeHomeBinding,
isProjectHomeChannel,
type ProjectHomeCandidate,
} from "./projectHomeSelection";

export function useIsProjectHomeChannel(channelId: string | null | undefined) {
const projectsQuery = useProjectsQuery();
Expand Down
60 changes: 60 additions & 0 deletions desktop/src/features/projects/lib/projectHomeSelection.ts
Original file line number Diff line number Diff line change
@@ -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),
);
}
Loading
Loading