1import { SymbolView } from "./AppSymbol";
2import { AppText } from "./AppText";
3import { Image } from "expo-image";
4import { memo, useLayoutEffect, useMemo, useState } from "react";
5import { View } from "react-native";
6import type { EnvironmentId, ProjectIconOverride } from "@t3tools/contracts";
7import {
8 getProjectFaviconCacheKey,
9 getProjectFaviconResourceKey,
10 isProjectFaviconFallbackUrl,
11} from "@t3tools/shared/projectFavicon";
12import { useAtomValue } from "@effect/atom-react";
13import { Atom } from "effect/reactivity";
14import { projectFaviconUrlAtom } from "../state/assets";
15import {
16 countGlyphs,
17 projectIconColorClassNames,
18 resolveProjectIconGlyph,
19 type ProjectIconGlyph,
20} from "../lib/projectIcon";
21
22import {
23 beginProjectFaviconRequest,
24 createProjectFaviconRequest,
25 hasLoadedProjectFavicon,
26 markProjectFaviconFailed,
27 markProjectFaviconLoaded,
28} from "../lib/projectFaviconRequests";
29
30const EMPTY_FAVICON_URL = Atom.make<string | null>(null);
31
32/* ─── Component ──────────────────────────────────────────────────────── */
33export const ProjectFavicon = memo(function ProjectFavicon(props: {
34 readonly environmentId: EnvironmentId;
35 readonly open?: boolean;
36 readonly size?: number;
37 readonly projectTitle: string;
38 readonly workspaceRoot?: string | null;
39 readonly faviconPath?: string | null;
40 readonly projectIcon?: ProjectIconOverride | null;
41}) {
42 const size = props.size ?? 42;
43 const glyph = resolveProjectIconGlyph(props.projectIcon, props.projectTitle);
44 const faviconUrl = useAtomValue(
45 props.workspaceRoot == null || glyph !== null
46 ? EMPTY_FAVICON_URL
47 : projectFaviconUrlAtom({
48 environmentId: props.environmentId,
49 cwd: props.workspaceRoot,
50 faviconPath: props.faviconPath,
51 }),
52 );
53 const renderableFaviconUrl = useMemo(
54 () => (isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl),
55 [faviconUrl],
56 );
57 // Inline images are self-contained; remote URLs key on their revision so signed-token
58 // rotation reuses the disk cache while a changed icon starts from the loading state.
59 const cacheKey = useMemo(
60 () =>
61 renderableFaviconUrl && props.workspaceRoot
62 ? renderableFaviconUrl.startsWith("data:")
63 ? getProjectFaviconResourceKey(
64 props.environmentId,
65 props.workspaceRoot,
66 props.faviconPath,
67 )
68 : getProjectFaviconCacheKey(
69 props.environmentId,
70 props.workspaceRoot,
71 renderableFaviconUrl,
72 )
73 : null,
74 [renderableFaviconUrl, props.environmentId, props.workspaceRoot, props.faviconPath],
75 );
76
77 if (glyph !== null) {
78 return <ProjectIconGlyphView glyph={glyph} size={size} />;
79 }
80
81 return (
82 <ProjectFaviconImage
83 key={cacheKey}
84 cacheKey={cacheKey}
85 faviconUrl={renderableFaviconUrl}
86 open={props.open}
87 projectTitle={props.projectTitle}
88 size={size}
89 />
90 );
91});
92
93function ProjectIconGlyphView(props: { readonly glyph: ProjectIconGlyph; readonly size: number }) {
94 const { glyph, size } = props;
95 if (glyph.kind === "emoji") {
96 return (
97 <View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
98 <AppText
99 allowFontScaling={false}
100 style={{
101 fontSize: size * 0.8,
102 lineHeight: size,
103 textAlign: "center",
104 includeFontPadding: false,
105 }}
106 >
107 {glyph.emoji}
108 </AppText>
109 </View>
110 );
111 }
112
113 const colors = projectIconColorClassNames(glyph.color);
114 return (
115 <View
116 className={colors.background}
117 style={{
118 width: size,
119 height: size,
120 borderRadius: size * 0.25,
121 alignItems: "center",
122 justifyContent: "center",
123 }}
124 >
125 <AppText
126 allowFontScaling={false}
127 numberOfLines={1}
128 className={`font-mono ${colors.text}`}
129 style={{
130 fontWeight: "700",
131 fontSize: size * (countGlyphs(glyph.text) === 1 ? 0.6 : 0.515625),
132 lineHeight: size,
133 textAlign: "center",
134 includeFontPadding: false,
135 }}
136 >
137 {glyph.text}
138 </AppText>
139 </View>
140 );
141}
142
143function ProjectFaviconImage(props: {
144 readonly cacheKey: string | null;
145 readonly faviconUrl: string | null;
146 readonly open?: boolean;
147 readonly projectTitle: string;
148 readonly size: number;
149}) {
150 const faviconRequest = useMemo(
151 () => createProjectFaviconRequest(props.cacheKey, props.faviconUrl),
152 [props.cacheKey, props.faviconUrl],
153 );
154 const [activeFaviconRequest, setActiveFaviconRequest] = useState<typeof faviconRequest>(null);
155 useLayoutEffect(() => {
156 if (faviconRequest === null) return;
157
158 const endRequest = beginProjectFaviconRequest(faviconRequest);
159 setActiveFaviconRequest(faviconRequest);
160 return endRequest;
161 }, [faviconRequest]);
162
163 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
164 props.faviconUrl?.startsWith("data:") || hasLoadedProjectFavicon(props.cacheKey)
165 ? "loaded"
166 : "loading",
167 );
168
169 const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest;
170 const showImage = requestIsActive && status === "loaded";
171
172 return (
173 <View
174 style={{
175 width: props.size,
176 height: props.size,
177 alignItems: "center",
178 justifyContent: "center",
179 }}
180 >
181 {/* Folder icon fallback (matches web's FolderIcon) */}
182 {!showImage ? (
183 <SymbolView
184 name={{ ios: "folder.fill", android: props.open ? "folder_open" : "folder" }}
185 size={props.size}
186 tintColorClassName={"accent-icon-subtle"}
187 type="monochrome"
188 />
189 ) : null}
190
191 {/* Favicon image (hidden until loaded) */}
192 {requestIsActive ? (
193 <Image
194 key={faviconRequest.faviconUrl}
195 source={
196 faviconRequest.faviconUrl.startsWith("data:")
197 ? { uri: faviconRequest.faviconUrl }
198 : { uri: faviconRequest.faviconUrl, cacheKey: faviconRequest.cacheKey }
199 }
200 cachePolicy={faviconRequest.faviconUrl.startsWith("data:") ? "memory" : "memory-disk"}
201 recyclingKey={faviconRequest.cacheKey}
202 accessibilityLabel={`${props.projectTitle} favicon`}
203 style={{
204 width: props.size,
205 height: props.size,
206 borderRadius: props.size * 0.16,
207 ...(showImage ? {} : { position: "absolute" as const, opacity: 0 }),
208 }}
209 contentFit="contain"
210 onLoad={() => {
211 if (!markProjectFaviconLoaded(faviconRequest)) return;
212 setStatus("loaded");
213 }}
214 onError={() => {
215 if (!markProjectFaviconFailed(faviconRequest)) return;
216 setStatus("error");
217 }}
218 />
219 ) : null}
220 </View>
221 );
222}