| author | |
| committer | |
| log | 1c1270663880903b16568594c4bf5f322407903a |
| tree | 07d613f1ed06fa9f7dfbb73d5849c0dea1b7b85d |
| parent | e55e7315e4a4c3870711acd6825ea5347c2d4812 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>6 files changed, 158 insertions(+), 7 deletions(-)
apps/mobile/src/components/ProjectFavicon.tsx+66-3| ... | @@ -1,8 +1,9 @@ | ... | @@ -1,8 +1,9 @@ |
| 1 | import { SymbolView } from "./AppSymbol"; | 1 | import { SymbolView } from "./AppSymbol"; |
| 2 | import { AppText } from "./AppText"; | ||
| 2 | import { Image } from "expo-image"; | 3 | import { Image } from "expo-image"; |
| 3 | import { useLayoutEffect, useMemo, useState } from "react"; | 4 | import { useLayoutEffect, useMemo, useState } from "react"; |
| 4 | import { View } from "react-native"; | 5 | import { View } from "react-native"; |
| 5 | import type { EnvironmentId } from "@t3tools/contracts"; | 6 | import type { EnvironmentId, ProjectIconOverride } from "@t3tools/contracts"; |
| 6 | import { | 7 | import { |
| 7 | getProjectFaviconCacheKey, | 8 | getProjectFaviconCacheKey, |
| 8 | getProjectFaviconResourceKey, | 9 | getProjectFaviconResourceKey, |
| ... | @@ -11,6 +12,12 @@ import { | ... | @@ -11,6 +12,12 @@ import { |
| 11 | import { useAtomValue } from "@effect/atom-react"; | 12 | import { useAtomValue } from "@effect/atom-react"; |
| 12 | import { Atom } from "effect/unstable/reactivity"; | 13 | import { Atom } from "effect/unstable/reactivity"; |
| 13 | import { projectFaviconUrlAtom } from "../state/assets"; | 14 | import { projectFaviconUrlAtom } from "../state/assets"; |
| 15 | import { | ||
| 16 | countGlyphs, | ||
| 17 | projectIconColorClassNames, | ||
| 18 | resolveProjectIconGlyph, | ||
| 19 | type ProjectIconGlyph, | ||
| 20 | } from "../lib/projectIcon"; | ||
| 14 | 21 | ||
| 15 | import { | 22 | import { |
| 16 | beginProjectFaviconRequest, | 23 | beginProjectFaviconRequest, |
| ... | @@ -30,10 +37,12 @@ export function ProjectFavicon(props: { | ... | @@ -30,10 +37,12 @@ export function ProjectFavicon(props: { |
| 30 | readonly projectTitle: string; | 37 | readonly projectTitle: string; |
| 31 | readonly workspaceRoot?: string | null; | 38 | readonly workspaceRoot?: string | null; |
| 32 | readonly faviconPath?: string | null; | 39 | readonly faviconPath?: string | null; |
| 40 | readonly projectIcon?: ProjectIconOverride | null; | ||
| 33 | }) { | 41 | }) { |
| 34 | const size = props.size ?? 42; | 42 | const size = props.size ?? 42; |
| 43 | const glyph = resolveProjectIconGlyph(props.projectIcon, props.projectTitle); | ||
| 35 | const faviconUrl = useAtomValue( | 44 | const faviconUrl = useAtomValue( |
| 36 | props.workspaceRoot == null | 45 | props.workspaceRoot == null || glyph !== null |
| 37 | ? EMPTY_FAVICON_URL | 46 | ? EMPTY_FAVICON_URL |
| 38 | : projectFaviconUrlAtom({ | 47 | : projectFaviconUrlAtom({ |
| 39 | environmentId: props.environmentId, | 48 | environmentId: props.environmentId, |
| ... | @@ -51,6 +60,10 @@ export function ProjectFavicon(props: { | ... | @@ -51,6 +60,10 @@ export function ProjectFavicon(props: { |
| 51 | : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) | 60 | : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) |
| 52 | : null; | 61 | : null; |
| 53 | 62 | ||
| 63 | if (glyph !== null) { | ||
| 64 | return <ProjectIconGlyphView glyph={glyph} size={size} />; | ||
| 65 | } | ||
| 66 | |||
| 54 | return ( | 67 | return ( |
| 55 | <ProjectFaviconImage | 68 | <ProjectFaviconImage |
| 56 | key={cacheKey} | 69 | key={cacheKey} |
| ... | @@ -63,6 +76,56 @@ export function ProjectFavicon(props: { | ... | @@ -63,6 +76,56 @@ export function ProjectFavicon(props: { |
| 63 | ); | 76 | ); |
| 64 | } | 77 | } |
| 65 | 78 | ||
| 79 | function ProjectIconGlyphView(props: { readonly glyph: ProjectIconGlyph; readonly size: number }) { | ||
| 80 | const { glyph, size } = props; | ||
| 81 | if (glyph.kind === "emoji") { | ||
| 82 | return ( | ||
| 83 | <View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}> | ||
| 84 | <AppText | ||
| 85 | allowFontScaling={false} | ||
| 86 | style={{ | ||
| 87 | fontSize: size * 0.8, | ||
| 88 | lineHeight: size, | ||
| 89 | textAlign: "center", | ||
| 90 | includeFontPadding: false, | ||
| 91 | }} | ||
| 92 | > | ||
| 93 | {glyph.emoji} | ||
| 94 | </AppText> | ||
| 95 | </View> | ||
| 96 | ); | ||
| 97 | } | ||
| 98 | |||
| 99 | const colors = projectIconColorClassNames(glyph.color); | ||
| 100 | return ( | ||
| 101 | <View | ||
| 102 | className={colors.background} | ||
| 103 | style={{ | ||
| 104 | width: size, | ||
| 105 | height: size, | ||
| 106 | borderRadius: size * 0.25, | ||
| 107 | alignItems: "center", | ||
| 108 | justifyContent: "center", | ||
| 109 | }} | ||
| 110 | > | ||
| 111 | <AppText | ||
| 112 | allowFontScaling={false} | ||
| 113 | numberOfLines={1} | ||
| 114 | className={`font-mono ${colors.text}`} | ||
| 115 | style={{ | ||
| 116 | fontWeight: "700", | ||
| 117 | fontSize: size * (countGlyphs(glyph.text) === 1 ? 0.6 : 0.515625), | ||
| 118 | lineHeight: size, | ||
| 119 | textAlign: "center", | ||
| 120 | includeFontPadding: false, | ||
| 121 | }} | ||
| 122 | > | ||
| 123 | {glyph.text} | ||
| 124 | </AppText> | ||
| 125 | </View> | ||
| 126 | ); | ||
| 127 | } | ||
| 128 | |||
| 66 | function ProjectFaviconImage(props: { | 129 | function ProjectFaviconImage(props: { |
| 67 | readonly cacheKey: string | null; | 130 | readonly cacheKey: string | null; |
| 68 | readonly faviconUrl: string | null; | 131 | readonly faviconUrl: string | null; |
| ... | @@ -105,7 +168,7 @@ function ProjectFaviconImage(props: { | ... | @@ -105,7 +168,7 @@ function ProjectFaviconImage(props: { |
| 105 | {!showImage ? ( | 168 | {!showImage ? ( |
| 106 | <SymbolView | 169 | <SymbolView |
| 107 | name={{ ios: "folder.fill", android: props.open ? "folder_open" : "folder" }} | 170 | name={{ ios: "folder.fill", android: props.open ? "folder_open" : "folder" }} |
| 108 | size={props.size * 0.78} | 171 | size={props.size} |
| 109 | tintColorClassName={"accent-icon-subtle"} | 172 | tintColorClassName={"accent-icon-subtle"} |
| 110 | type="monochrome" | 173 | type="monochrome" |
| 111 | /> | 174 | /> |
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+1| ... | @@ -151,6 +151,7 @@ function ProjectGroupLabel(props: { | ... | @@ -151,6 +151,7 @@ function ProjectGroupLabel(props: { |
| 151 | <ProjectFavicon | 151 | <ProjectFavicon |
| 152 | environmentId={props.project.environmentId} | 152 | environmentId={props.project.environmentId} |
| 153 | faviconPath={props.project.faviconPath} | 153 | faviconPath={props.project.faviconPath} |
| 154 | projectIcon={props.project.projectIcon} | ||
| 154 | projectTitle={props.project.title} | 155 | projectTitle={props.project.title} |
| 155 | size={18} | 156 | size={18} |
| 156 | workspaceRoot={props.project.workspaceRoot} | 157 | workspaceRoot={props.project.workspaceRoot} |
apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx+2-1| ... | @@ -96,9 +96,10 @@ function ProjectOverviewContent(props: { | ... | @@ -96,9 +96,10 @@ function ProjectOverviewContent(props: { |
| 96 | <View className="flex-row items-center gap-4 px-2"> | 96 | <View className="flex-row items-center gap-4 px-2"> |
| 97 | <ProjectFavicon | 97 | <ProjectFavicon |
| 98 | environmentId={representative.environmentId} | 98 | environmentId={representative.environmentId} |
| 99 | projectTitle={displayName} | 99 | projectTitle={representative.title} |
| 100 | workspaceRoot={representative.workspaceRoot} | 100 | workspaceRoot={representative.workspaceRoot} |
| 101 | faviconPath={representative.faviconPath} | 101 | faviconPath={representative.faviconPath} |
| 102 | projectIcon={representative.projectIcon} | ||
| 102 | size={48} | 103 | size={48} |
| 103 | /> | 104 | /> |
| 104 | <View className="min-w-0 flex-1"> | 105 | <View className="min-w-0 flex-1"> |
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+2| ... | @@ -333,6 +333,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara | ... | @@ -333,6 +333,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 333 | <ProjectFavicon | 333 | <ProjectFavicon |
| 334 | environmentId={scope.representative.environmentId} | 334 | environmentId={scope.representative.environmentId} |
| 335 | faviconPath={scope.representative.faviconPath} | 335 | faviconPath={scope.representative.faviconPath} |
| 336 | projectIcon={scope.representative.projectIcon} | ||
| 336 | size={24} | 337 | size={24} |
| 337 | projectTitle={scope.title} | 338 | projectTitle={scope.title} |
| 338 | workspaceRoot={scope.representative.workspaceRoot} | 339 | workspaceRoot={scope.representative.workspaceRoot} |
| ... | @@ -357,6 +358,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara | ... | @@ -357,6 +358,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 357 | <ProjectFavicon | 358 | <ProjectFavicon |
| 358 | environmentId={scope.representative.environmentId} | 359 | environmentId={scope.representative.environmentId} |
| 359 | faviconPath={scope.representative.faviconPath} | 360 | faviconPath={scope.representative.faviconPath} |
| 361 | projectIcon={scope.representative.projectIcon} | ||
| 360 | size={20} | 362 | size={20} |
| 361 | projectTitle={scope.title} | 363 | projectTitle={scope.title} |
| 362 | workspaceRoot={scope.representative.workspaceRoot} | 364 | workspaceRoot={scope.representative.workspaceRoot} |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+6-3| ... | @@ -294,8 +294,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props | ... | @@ -294,8 +294,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props |
| 294 | <ProjectFavicon | 294 | <ProjectFavicon |
| 295 | environmentId={pendingTask.environmentId} | 295 | environmentId={pendingTask.environmentId} |
| 296 | faviconPath={props.project.faviconPath} | 296 | faviconPath={props.project.faviconPath} |
| 297 | projectIcon={props.project.projectIcon} | ||
| 297 | size={15} | 298 | size={15} |
| 298 | projectTitle={projectTitle} | 299 | projectTitle={props.project.title} |
| 299 | workspaceRoot={props.project.workspaceRoot} | 300 | workspaceRoot={props.project.workspaceRoot} |
| 300 | /> | 301 | /> |
| 301 | ) : null} | 302 | ) : null} |
| ... | @@ -890,8 +891,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -890,8 +891,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 890 | <ProjectFavicon | 891 | <ProjectFavicon |
| 891 | environmentId={thread.environmentId} | 892 | environmentId={thread.environmentId} |
| 892 | faviconPath={props.project.faviconPath} | 893 | faviconPath={props.project.faviconPath} |
| 894 | projectIcon={props.project.projectIcon} | ||
| 893 | size={15} | 895 | size={15} |
| 894 | projectTitle={props.projectTitle ?? props.project.title} | 896 | projectTitle={props.project.title} |
| 895 | workspaceRoot={props.project.workspaceRoot} | 897 | workspaceRoot={props.project.workspaceRoot} |
| 896 | /> | 898 | /> |
| 897 | ) : null} | 899 | ) : null} |
| ... | @@ -1124,8 +1126,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { | ... | @@ -1124,8 +1126,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 1124 | <ProjectFavicon | 1126 | <ProjectFavicon |
| 1125 | environmentId={thread.environmentId} | 1127 | environmentId={thread.environmentId} |
| 1126 | faviconPath={props.project.faviconPath} | 1128 | faviconPath={props.project.faviconPath} |
| 1129 | projectIcon={props.project.projectIcon} | ||
| 1127 | size={15} | 1130 | size={15} |
| 1128 | projectTitle={props.projectTitle ?? props.project.title} | 1131 | projectTitle={props.project.title} |
| 1129 | workspaceRoot={props.project.workspaceRoot} | 1132 | workspaceRoot={props.project.workspaceRoot} |
| 1130 | /> | 1133 | /> |
| 1131 | </View> | 1134 | </View> |
apps/mobile/src/lib/projectIcon.ts created+81| ... | @@ -0,0 +1,81 @@ | ||
| 1 | import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export type ProjectIconGlyph = | ||
| 4 | | { readonly kind: "emoji"; readonly emoji: string } | ||
| 5 | | { readonly kind: "monogram"; readonly text: string; readonly color: ProjectIconColor }; | ||
| 6 | |||
| 7 | /** | ||
| 8 | * Visible glyph count for sizing monogram text. Hermes has no Intl.Segmenter, so combining | ||
| 9 | * marks are folded into their base character instead of full grapheme segmentation. | ||
| 10 | */ | ||
| 11 | export function countGlyphs(text: string): number { | ||
| 12 | return Array.from(text.replace(/\p{M}/gu, "")).length; | ||
| 13 | } | ||
| 14 | |||
| 15 | /** Mirrors the automatic monogram web derives from a project name when it has no favicon. */ | ||
| 16 | export function projectMonogram(projectName: string): string { | ||
| 17 | const words = | ||
| 18 | projectName | ||
| 19 | .normalize("NFKC") | ||
| 20 | .trim() | ||
| 21 | .match(/[\p{L}\p{N}]+/gu) ?? []; | ||
| 22 | const firstWord = words[0]; | ||
| 23 | if (!firstWord) return "PR"; | ||
| 24 | |||
| 25 | const glyphs = Array.from(firstWord); | ||
| 26 | const first = glyphs[0] ?? "P"; | ||
| 27 | const second = | ||
| 28 | glyphs.slice(1).find((glyph) => /\p{N}/u.test(glyph)) ?? | ||
| 29 | (words.length > 1 ? Array.from(words.at(-1) ?? "")[0] : glyphs.at(-1)) ?? | ||
| 30 | first; | ||
| 31 | return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join(""); | ||
| 32 | } | ||
| 33 | |||
| 34 | /** | ||
| 35 | * Picks what mobile draws for an assigned project icon. Mobile does not bundle | ||
| 36 | * the Lucide set, so a Lucide override keeps its color and falls back to the | ||
| 37 | * project's monogram instead of the folder glyph. | ||
| 38 | */ | ||
| 39 | export function resolveProjectIconGlyph( | ||
| 40 | projectIcon: ProjectIconOverride | null | undefined, | ||
| 41 | projectTitle: string, | ||
| 42 | ): ProjectIconGlyph | null { | ||
| 43 | switch (projectIcon?.kind) { | ||
| 44 | case "emoji": | ||
| 45 | return { kind: "emoji", emoji: projectIcon.emoji }; | ||
| 46 | case "monogram": | ||
| 47 | return { kind: "monogram", text: projectIcon.text, color: projectIcon.color }; | ||
| 48 | case "lucide": | ||
| 49 | return { kind: "monogram", text: projectMonogram(projectTitle), color: projectIcon.color }; | ||
| 50 | case undefined: | ||
| 51 | return null; | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | const PROJECT_ICON_COLOR_CLASSES: Record< | ||
| 56 | ProjectIconColor, | ||
| 57 | { readonly text: string; readonly background: string } | ||
| 58 | > = { | ||
| 59 | gray: { text: "text-gray-500", background: "bg-gray-500/15" }, | ||
| 60 | red: { text: "text-red-500", background: "bg-red-500/15" }, | ||
| 61 | orange: { text: "text-orange-500", background: "bg-orange-500/15" }, | ||
| 62 | amber: { text: "text-amber-500", background: "bg-amber-500/15" }, | ||
| 63 | yellow: { text: "text-yellow-500", background: "bg-yellow-500/15" }, | ||
| 64 | lime: { text: "text-lime-500", background: "bg-lime-500/15" }, | ||
| 65 | green: { text: "text-green-500", background: "bg-green-500/15" }, | ||
| 66 | emerald: { text: "text-emerald-500", background: "bg-emerald-500/15" }, | ||
| 67 | teal: { text: "text-teal-500", background: "bg-teal-500/15" }, | ||
| 68 | cyan: { text: "text-cyan-500", background: "bg-cyan-500/15" }, | ||
| 69 | sky: { text: "text-sky-500", background: "bg-sky-500/15" }, | ||
| 70 | blue: { text: "text-blue-500", background: "bg-blue-500/15" }, | ||
| 71 | indigo: { text: "text-indigo-500", background: "bg-indigo-500/15" }, | ||
| 72 | violet: { text: "text-violet-500", background: "bg-violet-500/15" }, | ||
| 73 | purple: { text: "text-purple-500", background: "bg-purple-500/15" }, | ||
| 74 | fuchsia: { text: "text-fuchsia-500", background: "bg-fuchsia-500/15" }, | ||
| 75 | pink: { text: "text-pink-500", background: "bg-pink-500/15" }, | ||
| 76 | rose: { text: "text-rose-500", background: "bg-rose-500/15" }, | ||
| 77 | }; | ||
| 78 | |||
| 79 | export function projectIconColorClassNames(color: ProjectIconColor) { | ||
| 80 | return PROJECT_ICON_COLOR_CLASSES[color]; | ||
| 81 | } | ||