import { SymbolView } from "./AppSymbol";import { Image } from "expo-image";import { useLayoutEffect, useMemo, useState } from "react";import { View } from "react-native";import type { EnvironmentId } from "@t3tools/contracts";import { getProjectFaviconCacheKey, isProjectFaviconFallbackUrl,} from "@t3tools/shared/projectFavicon";import { useThemeColor } from "../lib/useThemeColor";import { useAssetUrl } from "../state/assets";import { beginProjectFaviconRequest, createProjectFaviconRequest, hasLoadedProjectFavicon, markProjectFaviconFailed, markProjectFaviconLoaded,} from "./projectFaviconCache"; /* ─── Component ──────────────────────────────────────────────────────── */export function ProjectFavicon(props: { readonly environmentId: EnvironmentId; readonly open?: boolean; readonly size?: number; readonly projectTitle: string; readonly workspaceRoot?: string | null;}) { const size = props.size ?? 42; const faviconUrl = useAssetUrl( props.environmentId, props.workspaceRoot === null || props.workspaceRoot === undefined ? null : { _tag: "project-favicon", cwd: props.workspaceRoot }, ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; const cacheKey = renderableFaviconUrl && props.workspaceRoot ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) : null; return ( <ProjectFaviconImage key={cacheKey} cacheKey={cacheKey} faviconUrl={renderableFaviconUrl} open={props.open} projectTitle={props.projectTitle} size={size} /> );} function ProjectFaviconImage(props: { readonly cacheKey: string | null; readonly faviconUrl: string | null; readonly open?: boolean; readonly projectTitle: string; readonly size: number;}) { const iconMuted = useThemeColor("--color-icon-subtle"); const faviconRequest = useMemo( () => createProjectFaviconRequest(props.cacheKey, props.faviconUrl), [props.cacheKey, props.faviconUrl], ); const [activeFaviconRequest, setActiveFaviconRequest] = useState<typeof faviconRequest>(null); useLayoutEffect(() => { if (faviconRequest === null) return; const endRequest = beginProjectFaviconRequest(faviconRequest); setActiveFaviconRequest(faviconRequest); return endRequest; }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", ); const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; const showImage = requestIsActive && status === "loaded"; return ( <View style={{ width: props.size, height: props.size, alignItems: "center", justifyContent: "center", }} > {/* Folder icon fallback (matches web's FolderIcon) */} {!showImage ? ( <SymbolView name={{ ios: "folder.fill", android: props.open ? "folder_open" : "folder" }} size={props.size * 0.78} tintColor={iconMuted} type="monochrome" /> ) : null} {/* Favicon image (hidden until loaded) */} {requestIsActive ? ( <Image key={faviconRequest.faviconUrl} source={{ uri: faviconRequest.faviconUrl, cacheKey: faviconRequest.cacheKey, }} cachePolicy="memory-disk" recyclingKey={faviconRequest.cacheKey} accessibilityLabel={`${props.projectTitle} favicon`} style={{ width: props.size, height: props.size, borderRadius: props.size * 0.16, ...(showImage ? {} : { position: "absolute" as const, opacity: 0 }), }} contentFit="contain" onLoad={() => { if (!markProjectFaviconLoaded(faviconRequest)) return; setStatus("loaded"); }} onError={() => { if (!markProjectFaviconFailed(faviconRequest)) return; setStatus("error"); }} /> ) : null} </View> );}
import { SymbolView } from "./AppSymbol";
import { Image } from "expo-image";
import { useLayoutEffect, useMemo, useState } from "react";
import { View } from "react-native";
import type { EnvironmentId } from "@t3tools/contracts";
import {
getProjectFaviconCacheKey,
isProjectFaviconFallbackUrl,
} from "@t3tools/shared/projectFavicon";
import { useThemeColor } from "../lib/useThemeColor";
import { useAssetUrl } from "../state/assets";
import {
beginProjectFaviconRequest,
createProjectFaviconRequest,
hasLoadedProjectFavicon,
markProjectFaviconFailed,
markProjectFaviconLoaded,
} from "./projectFaviconCache";
/* ─── Component ──────────────────────────────────────────────────────── */
export function ProjectFavicon(props: {
readonly environmentId: EnvironmentId;
readonly open?: boolean;
readonly size?: number;
readonly projectTitle: string;
readonly workspaceRoot?: string | null;
}) {
const size = props.size ?? 42;
const faviconUrl = useAssetUrl(
props.environmentId,
props.workspaceRoot === null || props.workspaceRoot === undefined
? null
: { _tag: "project-favicon", cwd: props.workspaceRoot },
);
const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;
const cacheKey =
renderableFaviconUrl && props.workspaceRoot
? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl)
: null;
return (
<ProjectFaviconImage
key={cacheKey}
cacheKey={cacheKey}
faviconUrl={renderableFaviconUrl}
open={props.open}
projectTitle={props.projectTitle}
size={size}
/>
);
}
function ProjectFaviconImage(props: {
readonly cacheKey: string | null;
readonly faviconUrl: string | null;
readonly open?: boolean;
readonly projectTitle: string;
readonly size: number;
}) {
const iconMuted = useThemeColor("--color-icon-subtle");
const faviconRequest = useMemo(
() => createProjectFaviconRequest(props.cacheKey, props.faviconUrl),
[props.cacheKey, props.faviconUrl],
);
const [activeFaviconRequest, setActiveFaviconRequest] = useState<typeof faviconRequest>(null);
useLayoutEffect(() => {
if (faviconRequest === null) return;
const endRequest = beginProjectFaviconRequest(faviconRequest);
setActiveFaviconRequest(faviconRequest);
return endRequest;
}, [faviconRequest]);
const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading",
);
const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest;
const showImage = requestIsActive && status === "loaded";
return (
<View
style={{
width: props.size,
height: props.size,
alignItems: "center",
justifyContent: "center",
}}
>
{/* Folder icon fallback (matches web's FolderIcon) */}
{!showImage ? (
<SymbolView
name={{ ios: "folder.fill", android: props.open ? "folder_open" : "folder" }}
size={props.size * 0.78}
tintColor={iconMuted}
type="monochrome"
/>
) : null}
{/* Favicon image (hidden until loaded) */}
{requestIsActive ? (
<Image
key={faviconRequest.faviconUrl}
source={{
uri: faviconRequest.faviconUrl,
cacheKey: faviconRequest.cacheKey,
}}
cachePolicy="memory-disk"
recyclingKey={faviconRequest.cacheKey}
accessibilityLabel={`${props.projectTitle} favicon`}
style={{
width: props.size,
height: props.size,
borderRadius: props.size * 0.16,
...(showImage ? {} : { position: "absolute" as const, opacity: 0 }),
}}
contentFit="contain"
onLoad={() => {
if (!markProjectFaviconLoaded(faviconRequest)) return;
setStatus("loaded");
}}
onError={() => {
if (!markProjectFaviconFailed(faviconRequest)) return;
setStatus("error");
}}
/>
) : null}
</View>
);
}