diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index a51191d479..18b01a02e9 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -4,7 +4,7 @@ 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 { getRelayHttpUrl, signRelayEvent } from "@/shared/api/tauri"; import { getIdentity } from "@/shared/api/tauriIdentity"; import { getProjectLocalRepoDiff, @@ -40,7 +40,7 @@ import type { } from "@/shared/api/types"; import { summarizeProjectActivityEvents } from "./projectActivity.mjs"; import { resolveProjectDefaultBranch } from "./lib/projectBranches"; -import { effectiveCloneUrls } from "./lib/projectCloneUrl"; +import { effectiveCloneUrls, resolveRelayOrigin } from "./lib/projectCloneUrl"; import type { ProjectIssue } from "./projectIssues.mjs"; import { projectIssueEventsToIssues } from "./projectIssues.mjs"; import type { @@ -247,7 +247,7 @@ function dedup(events: RelayEvent[]): RelayEvent[] { } export async function fetchProjects(): Promise { - const [events, deletionEvents] = await Promise.all([ + const [events, deletionEvents, relayOrigin] = await Promise.all([ relayClient.fetchEvents({ kinds: [KIND_REPO_ANNOUNCEMENT], limit: 200, @@ -256,10 +256,11 @@ export async function fetchProjects(): Promise { kinds: [KIND_DELETION], limit: 500, }), + resolveRelayOrigin(getCachedRelayOrigin(), getRelayHttpUrl), ]); return dedup(events) - .map((event) => eventToProject(event, getCachedRelayOrigin())) + .map((event) => eventToProject(event, relayOrigin)) .filter( (project) => !isHiddenLocally(project) && !isDeletedByA(project, deletionEvents), @@ -287,20 +288,21 @@ function parseProjectRouteId(projectId: string): { async function fetchProject(projectId: string): Promise { const { owner, dtag } = parseProjectRouteId(projectId); - const events = await relayClient.fetchEvents({ - kinds: [KIND_REPO_ANNOUNCEMENT], - ...(owner ? { authors: [owner] } : {}), - "#d": [dtag], - limit: 10, - }); + const [events, relayOrigin] = await Promise.all([ + relayClient.fetchEvents({ + kinds: [KIND_REPO_ANNOUNCEMENT], + ...(owner ? { authors: [owner] } : {}), + "#d": [dtag], + limit: 10, + }), + resolveRelayOrigin(getCachedRelayOrigin(), getRelayHttpUrl), + ]); const deduped = dedup(events).filter( (event) => !owner || event.pubkey.toLowerCase() === owner, ); const project = - deduped.length > 0 - ? eventToProject(deduped[0], getCachedRelayOrigin()) - : null; + deduped.length > 0 ? eventToProject(deduped[0], relayOrigin) : null; if (!project) { return null; } diff --git a/desktop/src/features/projects/lib/projectCloneUrl.test.mjs b/desktop/src/features/projects/lib/projectCloneUrl.test.mjs index 6179c46a04..b4b0bd65f2 100644 --- a/desktop/src/features/projects/lib/projectCloneUrl.test.mjs +++ b/desktop/src/features/projects/lib/projectCloneUrl.test.mjs @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { deriveRelayCloneUrl, effectiveCloneUrls } from "./projectCloneUrl.ts"; +import { + deriveRelayCloneUrl, + effectiveCloneUrls, + resolveRelayOrigin, +} from "./projectCloneUrl.ts"; const OWNER = "a".repeat(64); const ORIGIN = "https://relay.example"; @@ -60,3 +64,33 @@ test("effectiveCloneUrls derives a default when none is advertised", () => { test("effectiveCloneUrls returns empty when no default can be derived", () => { assert.deepEqual(effectiveCloneUrls([], null, OWNER, "repo"), []); }); + +test("resolveRelayOrigin uses the populated cache without fetching", async () => { + let calls = 0; + const origin = await resolveRelayOrigin(ORIGIN, async () => { + calls += 1; + return "https://wrong.example"; + }); + + assert.equal(origin, ORIGIN); + assert.equal(calls, 0); +}); + +test("resolveRelayOrigin fetches when startup cache is not ready", async () => { + let calls = 0; + const origin = await resolveRelayOrigin(null, async () => { + calls += 1; + return ORIGIN; + }); + + assert.equal(origin, ORIGIN); + assert.equal(calls, 1); +}); + +test("resolveRelayOrigin fails open for explicitly cloned projects", async () => { + const origin = await resolveRelayOrigin(null, async () => { + throw new Error("IPC unavailable"); + }); + + assert.equal(origin, null); +}); diff --git a/desktop/src/features/projects/lib/projectCloneUrl.ts b/desktop/src/features/projects/lib/projectCloneUrl.ts index 78dfb83762..6e415ba451 100644 --- a/desktop/src/features/projects/lib/projectCloneUrl.ts +++ b/desktop/src/features/projects/lib/projectCloneUrl.ts @@ -50,3 +50,22 @@ export function effectiveCloneUrls( const derived = deriveRelayCloneUrl(relayOrigin, owner, dtag); return derived ? [derived] : []; } + +/** + * Resolve the relay origin used by the missing-clone fallback. The media cache + * is only a fast path: project queries can run before its asynchronous startup + * probe completes, so a cache miss must resolve the origin directly instead of + * permanently mapping the announcement with an empty clone URL list. + */ +export async function resolveRelayOrigin( + cachedOrigin: string | null, + fetchRelayOrigin: () => Promise, +): Promise { + if (cachedOrigin) return cachedOrigin; + try { + return await fetchRelayOrigin(); + } catch { + // Preserve project discovery when the best-effort fallback lookup fails. + return null; + } +}