Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 @@ -118,6 +118,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
Expand Up @@ -13,8 +13,15 @@ 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 {
isAuthoritativeProjectData,
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 @@ -133,10 +140,23 @@ export function ChannelRouteScreen({
memberChannel ??
openDirectoryQuery.data?.find((channel) => channel.id === channelId) ??
null;
const projectHome = findProjectHomeByChannelId(
const enumeratedProjectHome = findProjectHomeByChannelId(
channelId,
projectsQuery.data ?? [],
);
const projectsAreAuthoritative = isAuthoritativeProjectData(
projectsQuery.dataUpdatedAt,
);
const projectHomeLookupQuery = useProjectHomeForChannelQuery(
channelId,
shouldUseScopedProjectHomeLookup({
dataUpdatedAt: projectsQuery.dataUpdatedAt,
hasEnumeratedProjectHome: Boolean(enumeratedProjectHome),
isHuddleTranscript,
}),
);
const projectHome =
enumeratedProjectHome ?? projectHomeLookupQuery.data ?? null;
const [targetMessageEvents, setTargetMessageEvents] = React.useState<
RelayEvent[]
>(() => {
Expand Down Expand Up @@ -276,6 +296,9 @@ export function ChannelRouteScreen({
if (projectHome && !isHuddleTranscript) {
return (
<ProjectChannelHome
allowRepositoryHealing={
projectsAreAuthoritative || Boolean(projectHomeLookupQuery.data)
}
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
75 changes: 28 additions & 47 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,11 @@ import {
type Project,
type Repository,
} from "./projectModels";
import {
buildProjectsFromFetcher,
type FetchProjectEventsExhaustively,
fetchProjectEventsExhaustively,
} from "./projectEnumeration";
import { fetchProjectHomeForChannel, fetchProjects } from "./projectFetch";
import { persistProjectSnapshot } from "./projectSnapshot";
import { projectMatchesRouteId } from "./projectRoutes";

export { fetchProjects } from "./projectFetch";
export { projectsQueryKey };

export type {
Expand All @@ -85,7 +82,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 +130,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 +149,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,19 +608,42 @@ 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);
persistProjectSnapshot(queryClient, projects);
return projects;
},
staleTime: PROJECTS_STALE_TIME_MS,
gcTime: PROJECTS_GC_TIME_MS,
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);
persistProjectSnapshot(queryClient, projects);
return projects;
},
select: (projects) =>
projects.find((project) => projectMatchesRouteId(project, projectId)) ??
null,
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