| 1 | import { SymbolView } from "./AppSymbol"; |
| 2 | import { AppText } from "./AppText"; |
| 3 | import { Image } from "expo-image"; |
| 4 | import { memo, useLayoutEffect, useMemo, useState } from "react"; |
| 5 | import { View } from "react-native"; |
| 6 | import type { EnvironmentId, ProjectIconOverride } from "@t3tools/contracts"; |
| 7 | import { |
| 8 | getProjectFaviconCacheKey, |
| 9 | getProjectFaviconResourceKey, |
| 10 | isProjectFaviconFallbackUrl, |
| 11 | } from "@t3tools/shared/projectFavicon"; |
| 12 | import { useAtomValue } from "@effect/atom-react"; |
| 13 | import { Atom } from "effect/reactivity"; |
| 14 | import { projectFaviconUrlAtom } from "../state/assets"; |
| 15 | import { |
| 16 | countGlyphs, |
| 17 | projectIconColorClassNames, |
| 18 | resolveProjectIconGlyph, |
| 19 | type ProjectIconGlyph, |
| 20 | } from "../lib/projectIcon"; |
| 21 | |
| 22 | import { |
| 23 | beginProjectFaviconRequest, |
| 24 | createProjectFaviconRequest, |
| 25 | hasLoadedProjectFavicon, |
| 26 | markProjectFaviconFailed, |
| 27 | markProjectFaviconLoaded, |
| 28 | } from "../lib/projectFaviconRequests"; |
| 29 | |
| 30 | const EMPTY_FAVICON_URL = Atom.make<string | null>(null); |
| 31 | |
| 32 | /* ─── Component ──────────────────────────────────────────────────────── */ |
| 33 | export 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 | |
| 93 | function 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 | |
| 143 | function 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 | } |