diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 932fc6779f20..0659c68654f6 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,8 +1,9 @@ import { SymbolView } from "./AppSymbol"; +import { AppText } from "./AppText"; import { Image } from "expo-image"; import { useLayoutEffect, useMemo, useState } from "react"; import { View } from "react-native"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectIconOverride } from "@t3tools/contracts"; import { getProjectFaviconCacheKey, getProjectFaviconResourceKey, @@ -11,6 +12,12 @@ import { import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/unstable/reactivity"; import { projectFaviconUrlAtom } from "../state/assets"; +import { + countGlyphs, + projectIconColorClassNames, + resolveProjectIconGlyph, + type ProjectIconGlyph, +} from "../lib/projectIcon"; import { beginProjectFaviconRequest, @@ -30,10 +37,12 @@ export function ProjectFavicon(props: { readonly projectTitle: string; readonly workspaceRoot?: string | null; readonly faviconPath?: string | null; + readonly projectIcon?: ProjectIconOverride | null; }) { const size = props.size ?? 42; + const glyph = resolveProjectIconGlyph(props.projectIcon, props.projectTitle); const faviconUrl = useAtomValue( - props.workspaceRoot == null + props.workspaceRoot == null || glyph !== null ? EMPTY_FAVICON_URL : projectFaviconUrlAtom({ environmentId: props.environmentId, @@ -51,6 +60,10 @@ export function ProjectFavicon(props: { : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) : null; + if (glyph !== null) { + return ; + } + return ( + + {glyph.emoji} + + + ); + } + + const colors = projectIconColorClassNames(glyph.color); + return ( + + + {glyph.text} + + + ); +} + function ProjectFaviconImage(props: { readonly cacheKey: string | null; readonly faviconUrl: string | null; diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index ea150180393c..b786f9f4066c 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -151,6 +151,7 @@ function ProjectGroupLabel(props: { diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index f74542076be2..27a9b3217c7e 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -333,6 +333,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps { + it("returns null without an override so the favicon path is used", () => { + expect(resolveProjectIconGlyph(null, "nebula")).toBeNull(); + expect(resolveProjectIconGlyph(undefined, "nebula")).toBeNull(); + }); + + it("passes emoji and monogram overrides through", () => { + expect(resolveProjectIconGlyph({ kind: "emoji", emoji: "🍎" }, "silver-orchard")).toEqual({ + kind: "emoji", + emoji: "🍎", + }); + expect( + resolveProjectIconGlyph({ kind: "monogram", text: "M7", color: "orange" }, "m7-forge"), + ).toEqual({ kind: "monogram", text: "M7", color: "orange" }); + }); + + it("turns a lucide override into a monogram that keeps the chosen color", () => { + expect( + resolveProjectIconGlyph({ kind: "lucide", name: "rocket", color: "violet" }, "nebula"), + ).toEqual({ kind: "monogram", text: "NA", color: "violet" }); + }); +}); + +describe("projectMonogram", () => { + it.each([ + ["nebula", "NA"], + ["silver-orchard", "SO"], + ["m7-forge", "M7"], + ["t3code", "T3"], + [" ", "PR"], + ])("derives %s -> %s", (title, expected) => { + expect(projectMonogram(title)).toBe(expected); + }); +}); diff --git a/apps/mobile/src/lib/projectIcon.ts b/apps/mobile/src/lib/projectIcon.ts new file mode 100644 index 000000000000..4e3914235a84 --- /dev/null +++ b/apps/mobile/src/lib/projectIcon.ts @@ -0,0 +1,78 @@ +import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; + +export type ProjectIconGlyph = + | { readonly kind: "emoji"; readonly emoji: string } + | { readonly kind: "monogram"; readonly text: string; readonly color: ProjectIconColor }; + +/** Code point count; Hermes has no Intl.Segmenter, and monogram text is at most two glyphs. */ +export function countGlyphs(text: string): number { + return Array.from(text).length; +} + +/** Mirrors the automatic monogram web derives from a project name when it has no favicon. */ +export function projectMonogram(projectName: string): string { + const words = + projectName + .normalize("NFKC") + .trim() + .match(/[\p{L}\p{N}]+/gu) ?? []; + const firstWord = words[0]; + if (!firstWord) return "PR"; + + const glyphs = Array.from(firstWord); + const first = glyphs[0] ?? "P"; + const second = + glyphs.slice(1).find((glyph) => /\p{N}/u.test(glyph)) ?? + (words.length > 1 ? Array.from(words.at(-1) ?? "")[0] : glyphs.at(-1)) ?? + first; + return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join(""); +} + +/** + * Picks what mobile draws for an assigned project icon. Mobile does not bundle + * the Lucide set, so a Lucide override keeps its color and falls back to the + * project's monogram instead of the folder glyph. + */ +export function resolveProjectIconGlyph( + projectIcon: ProjectIconOverride | null | undefined, + projectTitle: string, +): ProjectIconGlyph | null { + switch (projectIcon?.kind) { + case "emoji": + return { kind: "emoji", emoji: projectIcon.emoji }; + case "monogram": + return { kind: "monogram", text: projectIcon.text, color: projectIcon.color }; + case "lucide": + return { kind: "monogram", text: projectMonogram(projectTitle), color: projectIcon.color }; + case undefined: + return null; + } +} + +const PROJECT_ICON_COLOR_CLASSES: Record< + ProjectIconColor, + { readonly text: string; readonly background: string } +> = { + gray: { text: "text-gray-500", background: "bg-gray-500/15" }, + red: { text: "text-red-500", background: "bg-red-500/15" }, + orange: { text: "text-orange-500", background: "bg-orange-500/15" }, + amber: { text: "text-amber-500", background: "bg-amber-500/15" }, + yellow: { text: "text-yellow-500", background: "bg-yellow-500/15" }, + lime: { text: "text-lime-500", background: "bg-lime-500/15" }, + green: { text: "text-green-500", background: "bg-green-500/15" }, + emerald: { text: "text-emerald-500", background: "bg-emerald-500/15" }, + teal: { text: "text-teal-500", background: "bg-teal-500/15" }, + cyan: { text: "text-cyan-500", background: "bg-cyan-500/15" }, + sky: { text: "text-sky-500", background: "bg-sky-500/15" }, + blue: { text: "text-blue-500", background: "bg-blue-500/15" }, + indigo: { text: "text-indigo-500", background: "bg-indigo-500/15" }, + violet: { text: "text-violet-500", background: "bg-violet-500/15" }, + purple: { text: "text-purple-500", background: "bg-purple-500/15" }, + fuchsia: { text: "text-fuchsia-500", background: "bg-fuchsia-500/15" }, + pink: { text: "text-pink-500", background: "bg-pink-500/15" }, + rose: { text: "text-rose-500", background: "bg-rose-500/15" }, +}; + +export function projectIconColorClassNames(color: ProjectIconColor) { + return PROJECT_ICON_COLOR_CLASSES[color]; +}