Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions apps/mobile/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -51,6 +60,10 @@ export function ProjectFavicon(props: {
: getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl)
: null;

if (glyph !== null) {
return <ProjectIconGlyphView glyph={glyph} size={size} />;
}

return (
<ProjectFaviconImage
key={cacheKey}
Expand All @@ -63,6 +76,51 @@ export function ProjectFavicon(props: {
);
}

function ProjectIconGlyphView(props: { readonly glyph: ProjectIconGlyph; readonly size: number }) {
const { glyph, size } = props;
if (glyph.kind === "emoji") {
return (
<View style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
<AppText
allowFontScaling={false}
numberOfLines={1}
style={{ fontSize: size * 0.7, lineHeight: size, textAlign: "center" }}
>
{glyph.emoji}
</AppText>
</View>
);
}

const colors = projectIconColorClassNames(glyph.color);
return (
<View
className={colors.background}
style={{
width: size,
height: size,
borderRadius: size * 0.25,
alignItems: "center",
justifyContent: "center",
}}
>
<AppText
allowFontScaling={false}
numberOfLines={1}
className={`font-mono ${colors.text}`}
style={{
fontWeight: "700",
fontSize: size * (countGlyphs(glyph.text) === 1 ? 0.6 : 0.48),
lineHeight: size,
textAlign: "center",
}}
>
{glyph.text}
</AppText>
</View>
);
}

function ProjectFaviconImage(props: {
readonly cacheKey: string | null;
readonly faviconUrl: string | null;
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ function ProjectGroupLabel(props: {
<ProjectFavicon
environmentId={props.project.environmentId}
faviconPath={props.project.faviconPath}
projectIcon={props.project.projectIcon}
projectTitle={props.project.title}
size={18}
workspaceRoot={props.project.workspaceRoot}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ function ProjectOverviewContent(props: {
projectTitle={displayName}
workspaceRoot={representative.workspaceRoot}
faviconPath={representative.faviconPath}
projectIcon={representative.projectIcon}
size={48}
/>
<View className="min-w-0 flex-1">
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/NewTaskRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
<ProjectFavicon
environmentId={scope.representative.environmentId}
faviconPath={scope.representative.faviconPath}
projectIcon={scope.representative.projectIcon}
size={24}
projectTitle={scope.title}
workspaceRoot={scope.representative.workspaceRoot}
Expand All @@ -357,6 +358,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
<ProjectFavicon
environmentId={scope.representative.environmentId}
faviconPath={scope.representative.faviconPath}
projectIcon={scope.representative.projectIcon}
size={20}
projectTitle={scope.title}
workspaceRoot={scope.representative.workspaceRoot}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/thread-list-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props:
<ProjectFavicon
environmentId={props.project.environmentId}
faviconPath={props.project.faviconPath}
projectIcon={props.project.projectIcon}
open={!props.collapsed}
size={compact ? 22 : 18}
projectTitle={props.project.title}
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
<ProjectFavicon
environmentId={pendingTask.environmentId}
faviconPath={props.project.faviconPath}
projectIcon={props.project.projectIcon}
size={15}
projectTitle={projectTitle}
workspaceRoot={props.project.workspaceRoot}
Expand Down Expand Up @@ -831,6 +832,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
<ProjectFavicon
environmentId={thread.environmentId}
faviconPath={props.project.faviconPath}
projectIcon={props.project.projectIcon}
size={15}
projectTitle={props.projectTitle ?? props.project.title}
workspaceRoot={props.project.workspaceRoot}
Expand Down Expand Up @@ -1065,6 +1067,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
<ProjectFavicon
environmentId={thread.environmentId}
faviconPath={props.project.faviconPath}
projectIcon={props.project.projectIcon}
size={15}
projectTitle={props.projectTitle ?? props.project.title}
workspaceRoot={props.project.workspaceRoot}
Expand Down
38 changes: 38 additions & 0 deletions apps/mobile/src/lib/projectIcon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vite-plus/test";

import { projectMonogram, resolveProjectIconGlyph } from "./projectIcon";

describe("resolveProjectIconGlyph", () => {
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);
});
});
78 changes: 78 additions & 0 deletions apps/mobile/src/lib/projectIcon.ts
Original file line number Diff line number Diff line change
@@ -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];
}
Loading