From 087ffd90da860eb285142c429940b2c2e181f799 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 19 Oct 2026 18:41:41 -0400 Subject: [PATCH 01/11] feat(mobile): customize fonts and message text sizes --- apps/mobile/global.css | 2 + .../features/files/FileMarkdownPreview.tsx | 10 +- .../SettingsAppearanceRouteScreen.tsx | 2 + .../AppearancePreferencesProvider.tsx | 122 ++++++++- .../components/AppearancePreviews.tsx | 7 +- .../sections/FontAppearanceSection.tsx | 257 ++++++++++++++++++ .../src/features/threads/ThreadComposer.tsx | 10 +- .../src/features/threads/ThreadFeed.tsx | 136 +++++---- apps/mobile/src/lib/mobileFontFiles.test.ts | 115 ++++++++ apps/mobile/src/lib/mobileFontFiles.ts | 79 ++++++ .../src/lib/mobileFonts.test-fixtures.ts | 63 +++++ apps/mobile/src/lib/mobileFonts.test.ts | 149 ++++++++++ apps/mobile/src/lib/mobileFonts.ts | 231 ++++++++++++++++ .../src/lib/useMobileNavigationTheme.ts | 12 +- .../src/native/EnrichedMarkdownText.tsx | 10 +- .../native/SelectableMarkdownText.types.ts | 1 + .../src/native/T3ComposerEditor.ios.tsx | 11 +- .../src/native/T3ComposerEditor.native.tsx | 10 +- apps/mobile/src/native/T3ComposerEditor.tsx | 7 +- .../src/persistence/mobile-preferences.ts | 23 ++ docs/user/appearance.md | 10 + 21 files changed, 1186 insertions(+), 81 deletions(-) create mode 100644 apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx create mode 100644 apps/mobile/src/lib/mobileFontFiles.test.ts create mode 100644 apps/mobile/src/lib/mobileFontFiles.ts create mode 100644 apps/mobile/src/lib/mobileFonts.test-fixtures.ts create mode 100644 apps/mobile/src/lib/mobileFonts.test.ts create mode 100644 apps/mobile/src/lib/mobileFonts.ts diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 7a4cd4eff5046..9886ef4a3cc1f 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -46,10 +46,12 @@ /* ─── Custom utilities ──────────────────────────────────────────────── */ @utility font-t3-medium { font-family: var(--font-medium); + font-weight: 500; } @utility font-t3-bold { font-family: var(--font-bold); + font-weight: 700; } @utility font-t3-extrabold { diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index 73629f02fd253..6c3fd8cfbba4f 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -8,7 +8,6 @@ import { StackActions, useNavigation } from "@react-navigation/native"; import { RefreshControl, ScrollView, View } from "react-native"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; -import { FONT_FAMILIES } from "../../lib/fontFamilies"; import { resolveNativeMarkdownTypography } from "../../lib/appearancePreferences"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { @@ -39,8 +38,8 @@ interface MarkdownPreviewStyles { } export function useMarkdownPreviewStyles(): MarkdownPreviewStyles { - const { appearance } = useAppearancePreferences(); - const nativeMarkdownTypography = resolveNativeMarkdownTypography(appearance.baseFontSize); + const { fonts, replyFontSize } = useAppearancePreferences(); + const nativeMarkdownTypography = resolveNativeMarkdownTypography(replyFontSize); const theme = useUniwindTheme(); const body = theme["--color-md-body"]; const strong = theme["--color-md-strong"]; @@ -49,8 +48,8 @@ export function useMarkdownPreviewStyles(): MarkdownPreviewStyles { const codeBackground = theme["--color-md-code-bg"]; const codeText = theme["--color-md-code-text"]; const horizontalRule = theme["--color-md-hr"]; - const regularFontFamily = FONT_FAMILIES.regular; - const boldFontFamily = FONT_FAMILIES.bold; + const regularFontFamily = fonts.reply.regular; + const boldFontFamily = fonts.reply.bold; return { nativeTextStyle: { @@ -68,6 +67,7 @@ export function useMarkdownPreviewStyles(): MarkdownPreviewStyles { fontSize: nativeMarkdownTypography.fontSize, lineHeight: nativeMarkdownTypography.lineHeight, headingFontSizes: nativeMarkdownTypography.headingFontSizes, + codeFontFamily: fonts.code.regular, fontFamily: regularFontFamily, headingFontFamily: boldFontFamily, boldFontFamily, diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx index 717e71362a593..610e53786c037 100644 --- a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SettingsScreen } from "./components/SettingsScreen"; import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection"; import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; +import { FontAppearanceSection } from "./appearance/sections/FontAppearanceSection"; import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; import { ThemeAppearanceSection } from "./appearance/sections/ThemeAppearanceSection"; import { ToolCallsAppearanceSection } from "./appearance/sections/ToolCallsAppearanceSection"; @@ -27,6 +28,7 @@ export function SettingsAppearanceRouteScreen() { {Platform.OS === "android" ? : null} + diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index 26e17f45afb13..b30fa33a68030 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -19,6 +19,15 @@ import { } from "../../../lib/appearancePreferences"; import { appAtomRegistry } from "../../../state/atom-registry"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; +import { + normalizeFontSelections, + normalizeImportedFonts, + resolveMobileFonts, + type MobileFontRole, + type MobileFontSelections, +} from "../../../lib/mobileFonts"; +import { loadImportedFontFiles } from "../../../lib/mobileFontFiles"; +import { normalizeBaseFontSize } from "../../../lib/appearancePreferences"; import type { Preferences } from "../../../persistence/mobile-preferences"; import { isSystemColorsAvailable, @@ -45,6 +54,17 @@ import { } from "../../../lib/mobileThemeRuntime"; interface AppearancePreferencesContextValue { + readonly fonts: ReturnType; + readonly fontSelections: MobileFontSelections; + readonly importedFonts: ReturnType; + readonly unavailableFonts: readonly string[]; + readonly replyFontSize: number; + readonly composerFontSize: number; + readonly customReplyFontSize: boolean; + readonly customComposerFontSize: boolean; + readonly setFontSelection: (role: MobileFontRole, selection: string) => void; + readonly setReplyFontSize: (size: number | null) => void; + readonly setComposerFontSize: (size: number | null) => void; readonly appearance: ResolvedAppearance; readonly themeId: MobileThemeId; readonly themeIds: MobileThemeIds; @@ -222,6 +242,58 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN () => resolveAppearance({ baseFontSize, codeFontSize, codeWordBreak, terminalFontSize }), [baseFontSize, codeFontSize, codeWordBreak, terminalFontSize], ); + const fontSelections = useMemo( + () => normalizeFontSelections(storedPreferences?.fontSelections), + [storedPreferences?.fontSelections], + ); + const importedFonts = useMemo( + () => normalizeImportedFonts(storedPreferences?.importedFonts), + [storedPreferences?.importedFonts], + ); + const activeFontFiles = useMemo(() => { + const selected = new Set(Object.values(fontSelections)); + return importedFonts.filter((face) => selected.has(`imported:${face.family}`)); + }, [importedFonts, fontSelections]); + const libraryKey = activeFontFiles.map((face) => face.id).join(":"); + const [loadedFontFiles, setLoadedFontFiles] = useState<{ + key: string; + loaded: ReadonlySet; + unavailable: readonly string[]; + }>({ key: "", loaded: new Set(), unavailable: [] }); + useEffect(() => { + let cancelled = false; + void loadImportedFontFiles(activeFontFiles).then((result) => { + if (!cancelled) setLoadedFontFiles({ key: libraryKey, ...result }); + }); + return () => { + cancelled = true; + }; + }, [activeFontFiles, libraryKey]); + const fonts = useMemo( + () => + resolveMobileFonts( + fontSelections, + importedFonts, + loadedFontFiles.key === libraryKey ? loadedFontFiles.loaded : new Set(), + Platform.OS, + ), + [fontSelections, importedFonts, libraryKey, loadedFontFiles], + ); + const fontVariables = useMemo( + () => ({ + ...themeVariables, + "--font-sans": fonts.interface.regular, + "--font-medium": fonts.interface.medium, + "--font-bold": fonts.interface.bold, + }), + [themeVariables, fonts.interface], + ); + const replyFontSize = normalizeBaseFontSize(storedPreferences?.replyFontSize ?? baseFontSize); + const composerFontSize = normalizeBaseFontSize( + storedPreferences?.composerFontSize ?? baseFontSize, + ); + const customReplyFontSize = storedPreferences?.replyFontSize != null; + const customComposerFontSize = storedPreferences?.composerFontSize != null; const isReady = AsyncResult.isSuccess(preferencesResult); const updatePreferences = (patch: Partial) => { startTransition(() => savePreferences(patch)); @@ -290,12 +362,45 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN }; const mathRenderingEnabled = storedPreferences?.mathRenderingEnabled === true; - const setMathRenderingEnabled = (value: boolean) => - updatePreferences({ mathRenderingEnabled: value }); + const setMathRenderingEnabled = useCallback( + (value: boolean) => updatePreferences({ mathRenderingEnabled: value }), + [updatePreferences], + ); + const setFontSelection = useCallback( + (role: MobileFontRole, selection: string) => { + savePreferences({ + transform: (current) => ({ + fontSelections: { ...normalizeFontSelections(current.fontSelections), [role]: selection }, + }), + }); + }, + [savePreferences], + ); + const setReplyFontSize = useCallback( + (size: number | null) => + updatePreferences({ replyFontSize: size === null ? null : normalizeBaseFontSize(size) }), + [updatePreferences], + ); + const setComposerFontSize = useCallback( + (size: number | null) => + updatePreferences({ composerFontSize: size === null ? null : normalizeBaseFontSize(size) }), + [updatePreferences], + ); const value = useMemo( (): AppearancePreferencesContextValue => ({ appearance, + fonts, + fontSelections, + importedFonts, + unavailableFonts: loadedFontFiles.unavailable, + replyFontSize, + composerFontSize, + customReplyFontSize, + customComposerFontSize, + setFontSelection, + setReplyFontSize, + setComposerFontSize, themeId, themeIds, themeMode, @@ -320,6 +425,17 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN }), [ appearance, + fonts, + fontSelections, + importedFonts, + loadedFontFiles.unavailable, + replyFontSize, + composerFontSize, + customReplyFontSize, + customComposerFontSize, + setFontSelection, + setReplyFontSize, + setComposerFontSize, themeId, themeIds, themeMode, @@ -346,7 +462,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN return ( - {props.children} + {props.children} ); diff --git a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx index afcf4ba30d9a1..96d396e7c5b57 100644 --- a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx +++ b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx @@ -20,12 +20,17 @@ export function AppearancePreviewSeparator() { export function TextAppearancePreview(props: { readonly fontSize: number }) { const sizes = resolveMarkdownFontSizes(props.fontSize); + const { fonts } = useAppearancePreferences(); return ( The quick brown fox jumps over the lazy dog. diff --git a/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx new file mode 100644 index 0000000000000..b881c13de1600 --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx @@ -0,0 +1,257 @@ +import { useState } from "react"; +import { Alert, View } from "react-native"; +import { useAtomSet } from "@effect/atom-react"; +import * as DocumentPicker from "expo-document-picker"; +import { AppText as Text } from "../../../../components/ui/AppText"; +import { ListRowLayout } from "../../../../components/ui/ListRowLayout"; +import { Menu } from "../../../../components/ui/Menu"; +import { SettingsSection } from "../../components/SettingsSection"; +import { SettingsActionRow } from "../../components/SettingsActionRow"; +import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; +import { FontSizeSliderRow } from "../components/FontSizeSliderRow"; +import { useAppearancePreferences } from "../AppearancePreferencesProvider"; +import { updateMobilePreferencesAtom } from "../../../../state/preferences"; +import { + copyImportedFonts, + deleteImportedFontFiles, + loadImportedFontFiles, +} from "../../../../lib/mobileFontFiles"; +import { + BUILT_IN_FONT_OPTIONS, + DEFAULT_FONT_SELECTIONS, + MOBILE_FONT_ROLES, + importedFontFamilies, + mergeImportedFonts, + normalizeFontSelections, + normalizeImportedFonts, + type MobileFontRole, + type ImportedFontFace, +} from "../../../../lib/mobileFonts"; +import { MIN_BASE_FONT_SIZE, MAX_BASE_FONT_SIZE } from "../../../../lib/appearancePreferences"; + +const ROLES: readonly { role: MobileFontRole; label: string }[] = [ + { role: "interface", label: "Interface font" }, + { role: "reply", label: "Reply font" }, + { role: "composer", label: "Composer font" }, + { role: "code", label: "Chat code font" }, +]; + +export function FontAppearanceSection() { + const { + isReady, + fontSelections, + importedFonts, + unavailableFonts, + fonts, + replyFontSize, + composerFontSize, + customReplyFontSize, + customComposerFontSize, + setFontSelection, + setReplyFontSize, + setComposerFontSize, + } = useAppearancePreferences(); + const savePreferences = useAtomSet(updateMobilePreferencesAtom, { mode: "promise" }); + const [busy, setBusy] = useState(false); + const families = importedFontFamilies(importedFonts); + const options = [ + ...BUILT_IN_FONT_OPTIONS, + ...families.map((family) => ({ id: `imported:${family}`, label: family })), + ]; + const report = (error: unknown) => + Alert.alert( + "Custom fonts", + error instanceof Error ? error.message : "Could not update fonts. Please try again.", + ); + + const importFonts = async () => { + setBusy(true); + let copied: readonly ImportedFontFace[] = []; + let saved = false; + try { + const picked = await DocumentPicker.getDocumentAsync({ + type: "*/*", + multiple: true, + copyToCacheDirectory: true, + }); + if (picked.canceled) return; + copied = await copyImportedFonts(picked.assets); + const next = mergeImportedFonts(importedFonts, copied); + const loaded = await loadImportedFontFiles(next); + if (loaded.unavailable.length) + throw new Error( + `Could not load: ${loaded.unavailable.join(", ")}. Try another static TTF or OTF font.`, + ); + const result = await savePreferences({ + transform: (current) => ({ + importedFonts: mergeImportedFonts(normalizeImportedFonts(current.importedFonts), copied), + }), + }); + saved = true; + const retained = new Set(normalizeImportedFonts(result.importedFonts).map((face) => face.id)); + deleteImportedFontFiles( + [...importedFonts, ...copied].filter((face) => !retained.has(face.id)), + ); + } catch (error) { + if (!saved) deleteImportedFontFiles(copied); + report(error); + } finally { + setBusy(false); + } + }; + + const removeFamily = async (family: string) => { + setBusy(true); + try { + await savePreferences({ + transform: (current) => { + const selections = { ...normalizeFontSelections(current.fontSelections) }; + for (const role of MOBILE_FONT_ROLES) + if (selections[role] === `imported:${family}`) selections[role] = ""; + return { + fontSelections: selections, + importedFonts: normalizeImportedFonts(current.importedFonts).filter( + (face) => face.family !== family, + ), + }; + }, + }); + deleteImportedFontFiles(importedFonts.filter((face) => face.family === family)); + } catch (error) { + report(error); + } finally { + setBusy(false); + } + }; + + return ( + + + + A reply in your selected font. + + + Defaults follow the interface. Chat code uses a monospace default. These choices stay on + this device. + + + {ROLES.map(({ role, label }) => ( + ({ + id: option.id || "default", + title: option.label, + state: fontSelections[role] === option.id ? "on" : "off", + attributes: { disabled: !isReady || busy }, + }))} + onPressAction={({ nativeEvent }) => + setFontSelection(role, nativeEvent.event === "default" ? "" : nativeEvent.event) + } + > + + option.id === fontSelections[role])?.label ?? "Unavailable" + } + /> + + + ))} + setReplyFontSize(enabled ? replyFontSize : null)} + /> + {customReplyFontSize ? ( + + ) : null} + setComposerFontSize(enabled ? composerFontSize : null)} + /> + {customComposerFontSize ? ( + + ) : null} + void importFonts()} + /> + {unavailableFonts.length ? ( + + + Unavailable fonts: {unavailableFonts.join(", ")}. Using defaults; remove or reimport + these fonts. + + + ) : null} + {families.map((family) => ( + + Alert.alert( + "Remove font?", + `${family} will be removed from this device. Any settings using it will return to Default.`, + [ + { text: "Cancel", style: "cancel" }, + { text: "Remove", style: "destructive", onPress: () => void removeFamily(family) }, + ], + ) + } + /> + ))} + + void savePreferences({ + fontSelections: DEFAULT_FONT_SELECTIONS, + replyFontSize: null, + composerFontSize: null, + }).catch(report) + } + /> + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 9c2eb6d4c38df..b80eed697a1a5 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -417,11 +417,16 @@ export const THREAD_COMPOSER_BOTTOM_GUTTER = 6; export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps) { const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId)); - const { themeVariables: materialTheme } = useAppearancePreferences(); + const { themeVariables: materialTheme, fonts, composerFontSize } = useAppearancePreferences(); const composerPanel = materialTheme["--color-composer-panel"]; const navigation = useNavigation(); const foregroundColor = useUniwindTheme()["--color-foreground"]; - const bodyText = useScaledTextRole("body"); + const scaledBodyText = useScaledTextRole("body"); + const bodyText = { + ...scaledBodyText, + fontSize: composerFontSize, + lineHeight: Math.round((composerFontSize * 23) / 16), + }; const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); @@ -1063,6 +1068,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps {...compactEditor.editorProps} textStyle={{ ...bodyText, + fontFamily: fonts.composer.regular, color: foregroundColor, }} /> diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index dbbcaa1fd9cff..32557dd49297c 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -145,7 +145,6 @@ import { PresentationSource } from "../../components/ui/NativePresentation"; import { NativeScrollViewMarker } from "../../native/NativeScrollViewMarker"; import Animated, { FadeIn, FadeInDown, type SharedValue } from "react-native-reanimated"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import { FONT_FAMILIES } from "../../lib/fontFamilies"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; @@ -1260,8 +1259,15 @@ function MarkdownDetailsFold(props: { } function useMarkdownStyles(): MarkdownStyleSets { - const { appearance } = useAppearancePreferences(); - const nativeMarkdownTypography = resolveNativeMarkdownTypography(appearance.baseFontSize); + const { appearance, fonts, replyFontSize } = useAppearancePreferences(); + const replyTypography = useMemo( + () => resolveNativeMarkdownTypography(replyFontSize), + [replyFontSize], + ); + const nativeMarkdownTypography = useMemo( + () => resolveNativeMarkdownTypography(appearance.baseFontSize), + [appearance.baseFontSize], + ); const theme = useUniwindTheme(); const markdownBodyColor = theme["--color-md-body"]; const markdownStrongColor = theme["--color-md-strong"]; @@ -1281,55 +1287,80 @@ function useMarkdownStyles(): MarkdownStyleSets { const markdownUserCodeText = theme["--color-md-user-code-text"]; const markdownUserInlineCodeText = theme["--color-user-bubble-foreground-muted"]; const markdownUserFenceBg = theme["--color-md-user-fence-bg"]; - const regularFontFamily = FONT_FAMILIES.regular; - const boldFontFamily = FONT_FAMILIES.bold; - - return { - user: { - nativeTextStyle: { - color: markdownUserBodyColor, - strongColor: markdownUserBodyColor, - mutedColor: markdownUserBodyColor, - linkColor: markdownUserBodyColor, - inlineCodeColor: markdownUserInlineCodeText, - codeColor: markdownUserCodeText, - codeBackgroundColor: markdownUserCodeBg, - codeBlockBackgroundColor: markdownUserFenceBg, - fileTextColor: markdownUserBodyColor, - quoteMarkerColor: markdownUserBodyColor, - dividerColor: markdownUserBodyColor, - contextChipBorderColor, - fontSize: nativeMarkdownTypography.fontSize, - lineHeight: nativeMarkdownTypography.lineHeight, - headingFontSizes: nativeMarkdownTypography.headingFontSizes, - fontFamily: regularFontFamily, - headingFontFamily: boldFontFamily, - boldFontFamily, + const regularFontFamily = fonts.interface.regular; + const boldFontFamily = fonts.interface.bold; + + return useMemo(() => { + return { + user: { + nativeTextStyle: { + color: markdownUserBodyColor, + strongColor: markdownUserBodyColor, + mutedColor: markdownUserBodyColor, + linkColor: markdownUserBodyColor, + inlineCodeColor: markdownUserInlineCodeText, + codeColor: markdownUserCodeText, + codeBackgroundColor: markdownUserCodeBg, + codeBlockBackgroundColor: markdownUserFenceBg, + fileTextColor: markdownUserBodyColor, + quoteMarkerColor: markdownUserBodyColor, + dividerColor: markdownUserBodyColor, + contextChipBorderColor, + fontSize: nativeMarkdownTypography.fontSize, + lineHeight: nativeMarkdownTypography.lineHeight, + headingFontSizes: nativeMarkdownTypography.headingFontSizes, + codeFontFamily: fonts.code.regular, + fontFamily: regularFontFamily, + headingFontFamily: boldFontFamily, + boldFontFamily, + }, }, - }, - assistant: { - nativeTextStyle: { - color: markdownBodyColor, - strongColor: markdownStrongColor, - mutedColor: markdownBodyColor, - linkColor: markdownLinkColor, - inlineCodeColor: markdownInlineCodeText, - codeColor: markdownCodeText, - codeBackgroundColor: markdownCodeBg, - codeBlockBackgroundColor: markdownCodeBg, - fileTextColor: markdownCodeText, - quoteMarkerColor: markdownBlockquoteBorder, - dividerColor: markdownHrColor, - contextChipBorderColor, - fontSize: nativeMarkdownTypography.fontSize, - lineHeight: nativeMarkdownTypography.lineHeight, - headingFontSizes: nativeMarkdownTypography.headingFontSizes, - fontFamily: regularFontFamily, - headingFontFamily: boldFontFamily, - boldFontFamily, + assistant: { + nativeTextStyle: { + color: markdownBodyColor, + strongColor: markdownStrongColor, + mutedColor: markdownBodyColor, + linkColor: markdownLinkColor, + inlineCodeColor: markdownInlineCodeText, + codeColor: markdownCodeText, + codeBackgroundColor: markdownCodeBg, + codeBlockBackgroundColor: markdownCodeBg, + fileTextColor: markdownCodeText, + quoteMarkerColor: markdownBlockquoteBorder, + dividerColor: markdownHrColor, + contextChipBorderColor, + fontSize: replyTypography.fontSize, + lineHeight: replyTypography.lineHeight, + headingFontSizes: replyTypography.headingFontSizes, + codeFontFamily: fonts.code.regular, + fontFamily: fonts.reply.regular, + headingFontFamily: fonts.reply.bold, + boldFontFamily: fonts.reply.bold, + }, }, - }, - }; + }; + }, [ + fonts.reply, + fonts.code.regular, + replyTypography, + boldFontFamily, + contextChipBorderColor, + markdownBlockquoteBorder, + markdownBodyColor, + markdownCodeBg, + markdownCodeText, + markdownHrColor, + markdownInlineCodeText, + markdownLinkColor, + markdownStrongColor, + markdownUserBodyColor, + markdownUserCodeBg, + markdownUserCodeText, + markdownUserFenceBg, + markdownUserInlineCodeText, + nativeMarkdownTypography, + regularFontFamily, + ]); } /** Messages from other agents fold to one line so the user's own messages stay easy to find. */ @@ -2709,11 +2740,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const copyFeedbackTimeoutRef = useRef | null>(null); const headerMaterialVisibleRef = useRef(false); const { width: windowWidth, fontScale } = useWindowDimensions(); - const { appearance } = useAppearancePreferences(); + const { appearance, fonts, replyFontSize } = useAppearancePreferences(); const workRowSizing = deriveThreadWorkLogSizing({ baseFontSize: appearance.baseFontSize, fontScale, }); + const textLayoutKey = `${workRowSizing.textSizeKey}:${replyFontSize}:${fonts.interface.regular}:${fonts.interface.bold}:${fonts.reply.regular}:${fonts.reply.bold}:${fonts.code.regular}`; const settledPaneWidth = useWorkspaceSettledContentWidth(); const splitLayout = (props.layoutVariant ?? "compact") !== "compact"; const [measuredViewportWidth, setViewportWidth] = useState(() => (splitLayout ? 0 : windowWidth)); @@ -3617,7 +3649,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // reads getFixedItemSize only when it renders, so its size cache is cleared // after the commit that hands it rows at the new geometry; clearing earlier // re-caches old sizes. - const { textSizeKey } = workRowSizing; + const textSizeKey = textLayoutKey; const hasHtmlRenders = presentedFeed.some((entry) => entry.type === "html-render"); const previousRowGeometry = useRef({ textSizeKey, contentWidth }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears LegendList size caches after the commit that brings new row geometry diff --git a/apps/mobile/src/lib/mobileFontFiles.test.ts b/apps/mobile/src/lib/mobileFontFiles.test.ts new file mode 100644 index 0000000000000..041ffd21ca2e3 --- /dev/null +++ b/apps/mobile/src/lib/mobileFontFiles.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { fontFixture } from "./mobileFonts.test-fixtures"; + +const mocks = vi.hoisted(() => ({ + files: new Map(), + deleted: [] as string[], + load: vi.fn(), + nextId: 0, +})); +vi.mock("expo-crypto", () => ({ + randomUUID: () => `00000000-0000-4000-8000-${String(++mocks.nextId).padStart(12, "0")}`, +})); +vi.mock("expo-font", () => ({ isLoaded: () => false, loadAsync: mocks.load })); +vi.mock("expo-file-system", () => ({ + Paths: { document: "document://" }, + Directory: class { + uri: string; + constructor(base: string, part: string) { + this.uri = base + part; + } + create() {} + }, + File: class { + uri: string; + constructor(base: string | { uri: string }, file?: string) { + this.uri = typeof base === "string" ? base : base.uri + "/" + file; + } + get size() { + return mocks.files.get(this.uri)?.length ?? 0; + } + get exists() { + return mocks.files.has(this.uri); + } + async bytes() { + const data = mocks.files.get(this.uri); + if (!data) throw new Error("File missing"); + return data; + } + copy(target: { uri: string }) { + mocks.files.set(target.uri, mocks.files.get(this.uri)!); + } + delete() { + mocks.deleted.push(this.uri); + mocks.files.delete(this.uri); + } + }, +})); + +import { copyImportedFonts, loadImportedFontFiles } from "./mobileFontFiles"; + +beforeEach(() => { + mocks.files.clear(); + mocks.deleted.length = 0; + mocks.nextId = 0; + mocks.load.mockReset().mockResolvedValue(undefined); +}); + +describe("mobile font import files", () => { + it("copies selected fonts into private storage and reads the family from the file", async () => { + mocks.files.set( + "cache://misleading-name.ttf", + fontFixture({ typographicFamily: "Real Family", weight: 600 }), + ); + const [face] = await copyImportedFonts([ + { uri: "cache://misleading-name.ttf", name: "misleading-name.ttf" }, + ]); + expect(face).toMatchObject({ family: "Real Family", weight: 600, italic: false }); + expect(mocks.files.get(`document://custom-fonts/${face!.file}`)).toEqual( + mocks.files.get("cache://misleading-name.ttf"), + ); + expect(face).not.toHaveProperty("uri"); + }); + it("rolls back copied files if a later selected file is invalid", async () => { + mocks.files.set("cache://good.ttf", fontFixture()); + mocks.files.set("cache://bad.ttf", new Uint8Array(3)); + await expect( + copyImportedFonts([ + { uri: "cache://good.ttf", name: "good.ttf" }, + { uri: "cache://bad.ttf", name: "bad.ttf" }, + ]), + ).rejects.toThrow("incomplete"); + expect([...mocks.files.keys()].filter((key) => key.startsWith("document://"))).toEqual([]); + expect(mocks.deleted).toHaveLength(1); + }); + it("loads matching weights and italics together and isolates an unavailable family", async () => { + mocks.files.set("cache://regular.ttf", fontFixture({ family: "Readable" })); + mocks.files.set("cache://italic.ttf", fontFixture({ family: "Readable", italic: true })); + mocks.files.set("cache://missing.ttf", fontFixture({ family: "Unavailable" })); + const faces = await copyImportedFonts( + ["regular", "italic", "missing"].map((name) => ({ + uri: `cache://${name}.ttf`, + name: `${name}.ttf`, + })), + ); + const missingFile = faces[2]!.file; + mocks.load.mockImplementation(async (definitions) => { + if ( + definitions[0].fontDefinitions.some((face: { path: string }) => + face.path.endsWith(missingFile), + ) + ) + throw new Error("Font failed"); + }); + const result = await loadImportedFontFiles(faces); + expect(result.loaded.size).toBe(1); + expect(result.unavailable).toEqual(["Unavailable"]); + const readable = mocks.load.mock.calls.find( + ([definitions]) => definitions[0].fontDefinitions.length === 2, + )![0][0]; + expect(readable.fontDefinitions.map((face: { style: string }) => face.style)).toEqual([ + "normal", + "italic", + ]); + }); +}); diff --git a/apps/mobile/src/lib/mobileFontFiles.ts b/apps/mobile/src/lib/mobileFontFiles.ts new file mode 100644 index 0000000000000..e4e4d4ac87327 --- /dev/null +++ b/apps/mobile/src/lib/mobileFontFiles.ts @@ -0,0 +1,79 @@ +import { Directory, File, Paths } from "expo-file-system"; +import * as Font from "expo-font"; +import { randomUUID } from "expo-crypto"; +import { + importedFontFamilies, + importedFontVariant, + readFontMetadata, + type ImportedFontFace, +} from "./mobileFonts"; + +const directory = () => new Directory(Paths.document, "custom-fonts"); +const fontFile = (face: ImportedFontFace) => new File(directory(), face.file); + +export async function copyImportedFonts( + assets: readonly { readonly uri: string; readonly name: string }[], +) { + if (assets.length > 64) throw new Error("Choose up to 64 font files at a time."); + directory().create({ intermediates: true, idempotent: true }); + const imported: ImportedFontFace[] = []; + try { + for (const asset of assets) { + const extension = /\.(ttf|otf)$/i.exec(asset.name)?.[1]?.toLowerCase(); + if (!extension) throw new Error(`${asset.name}: choose a .ttf or .otf font.`); + const source = new File(asset.uri); + if (source.size > 20 * 1024 * 1024) + throw new Error(`${asset.name}: fonts must be smaller than 20 MB.`); + const metadata = readFontMetadata(await source.bytes()); + const id = randomUUID(); + const face = { ...metadata, id, file: `${id}.${extension}` }; + imported.push(face); + source.copy(fontFile(face)); + } + return imported; + } catch (error) { + deleteImportedFontFiles(imported); + throw error; + } +} + +export function deleteImportedFontFiles(faces: readonly ImportedFontFace[]) { + for (const face of faces) { + const file = fontFile(face); + if (file.exists) file.delete(); + } +} + +export async function loadImportedFontFiles(faces: readonly ImportedFontFace[]) { + const variants = new Map>>(); + for (const family of importedFontFamilies(faces)) { + for (const weight of [400, 500, 700]) { + const variant = importedFontVariant(faces, family, weight); + if (variant) variants.set(variant.alias, variant); + } + } + const loaded = new Set(); + const unavailable: string[] = []; + await Promise.all( + [...variants.values()].map(async (variant) => { + try { + if (!Font.isLoaded(variant.alias)) + await Font.loadAsync([ + { + fontFamily: variant.alias, + fontDefinitions: variant.faces.map((face) => ({ + path: fontFile(face).uri, + weight: face.weight, + style: face.italic ? ("italic" as const) : ("normal" as const), + })), + }, + ]); + loaded.add(variant.alias); + } catch { + const family = variant.faces[0]?.family; + if (family && !unavailable.includes(family)) unavailable.push(family); + } + }), + ); + return { loaded, unavailable }; +} diff --git a/apps/mobile/src/lib/mobileFonts.test-fixtures.ts b/apps/mobile/src/lib/mobileFonts.test-fixtures.ts new file mode 100644 index 0000000000000..71b068220004d --- /dev/null +++ b/apps/mobile/src/lib/mobileFonts.test-fixtures.ts @@ -0,0 +1,63 @@ +export function fontFixture( + options: { + family?: string; + typographicFamily?: string; + weight?: number; + italic?: boolean; + variable?: boolean; + } = {}, +) { + const records = [ + { id: 1, value: options.family ?? "Legacy Family" }, + ...(options.typographicFamily ? [{ id: 16, value: options.typographicFamily }] : []), + ]; + const strings = records.map((record) => { + const bytes = new Uint8Array(record.value.length * 2); + const view = new DataView(bytes.buffer); + [...record.value].forEach((_, index) => + view.setUint16(index * 2, record.value.charCodeAt(index)), + ); + return bytes; + }); + const nameLength = + 6 + records.length * 12 + strings.reduce((sum, string) => sum + string.length, 0); + const tables = [ + { tag: "name", length: nameLength }, + { tag: "OS/2", length: 64 }, + ...(options.variable ? [{ tag: "fvar", length: 16 }] : []), + ]; + const data = new Uint8Array( + 12 + tables.length * 16 + tables.reduce((sum, table) => sum + table.length, 0), + ); + const view = new DataView(data.buffer); + view.setUint32(0, 0x00010000); + view.setUint16(4, tables.length); + let offset = 12 + tables.length * 16; + for (const [index, table] of tables.entries()) { + const entry = 12 + index * 16; + for (let k = 0; k < 4; k++) data[entry + k] = table.tag.charCodeAt(k); + view.setUint32(entry + 8, offset); + view.setUint32(entry + 12, table.length); + if (table.tag === "name") { + view.setUint16(offset + 2, records.length); + view.setUint16(offset + 4, 6 + records.length * 12); + let relative = 0; + for (const [i, record] of records.entries()) { + const start = offset + 6 + i * 12; + view.setUint16(start, 3); + view.setUint16(start + 2, 1); + view.setUint16(start + 4, 0x409); + view.setUint16(start + 6, record.id); + view.setUint16(start + 8, strings[i]!.length); + view.setUint16(start + 10, relative); + data.set(strings[i]!, offset + 6 + records.length * 12 + relative); + relative += strings[i]!.length; + } + } else if (table.tag === "OS/2") { + view.setUint16(offset + 4, options.weight ?? 400); + view.setUint16(offset + 62, options.italic ? 1 : 0); + } + offset += table.length; + } + return data; +} diff --git a/apps/mobile/src/lib/mobileFonts.test.ts b/apps/mobile/src/lib/mobileFonts.test.ts new file mode 100644 index 0000000000000..90b00ff50f09d --- /dev/null +++ b/apps/mobile/src/lib/mobileFonts.test.ts @@ -0,0 +1,149 @@ +import { fontFixture } from "./mobileFonts.test-fixtures"; +import { describe, expect, it } from "vite-plus/test"; +import { + DEFAULT_FONT_SELECTIONS, + importedFontVariant, + mergeImportedFonts, + normalizeFontSelections, + normalizeImportedFonts, + readFontMetadata, + resolveMobileFonts, + type ImportedFontFace, +} from "./mobileFonts"; + +const regular: ImportedFontFace = { + id: "00000000-0000-4000-8000-000000000001", + file: "00000000-0000-4000-8000-000000000001.ttf", + family: "Example Serif", + weight: 400, + italic: false, +}; +const italic: ImportedFontFace = { + ...regular, + id: "00000000-0000-4000-8000-000000000002", + file: "00000000-0000-4000-8000-000000000002.ttf", + italic: true, +}; +const bold: ImportedFontFace = { + ...regular, + id: "00000000-0000-4000-8000-000000000003", + file: "00000000-0000-4000-8000-000000000003.ttf", + weight: 700, +}; +const foreign: ImportedFontFace = { + ...regular, + id: "00000000-0000-4000-8000-000000000004", + file: "00000000-0000-4000-8000-000000000004.ttf", + family: "Other Sans", + weight: 500, +}; + +describe("custom mobile font metadata", () => { + it("groups styles by the Unicode typographic family, not legacy names or filenames", () => { + expect( + readFontMetadata( + fontFixture({ + family: "Legacy Bold", + typographicFamily: "École Serif", + weight: 600, + italic: true, + }), + ), + ).toEqual({ family: "École Serif", weight: 600, italic: true }); + }); + it("falls back to the legacy family for static faces without typographic names", () => { + expect(readFontMetadata(fontFixture())).toEqual({ + family: "Legacy Family", + weight: 400, + italic: false, + }); + }); + it("explains unsupported variable files before they can be imported", () => { + expect(() => readFontMetadata(fontFixture({ variable: true }))).toThrow("static font faces"); + }); + it("rejects truncated headers and out-of-range name strings", () => { + expect(() => readFontMetadata(new Uint8Array(4))).toThrow("incomplete"); + const data = fontFixture(); + new DataView(data.buffer).setUint16(12 + 2 * 16 + 6 + 10, 65535); + expect(() => readFontMetadata(data)).toThrow("name table"); + }); +}); + +describe("custom mobile font choices", () => { + it("does not inherit invalid choices or unsafe stored file paths", () => { + expect( + normalizeFontSelections({ + interface: "serif", + reply: 42, + composer: "unknown", + code: "imported:Example Serif", + }), + ).toEqual({ interface: "serif", reply: "", composer: "", code: "imported:Example Serif" }); + expect( + normalizeImportedFonts([ + regular, + regular, + { ...italic, file: "../private.ttf" }, + { ...bold, weight: Infinity }, + ]), + ).toEqual([regular]); + }); + it("replaces only the matching face when reimporting a weight and style", () => { + const replacement = { + ...regular, + id: "00000000-0000-4000-8000-000000000005", + file: "00000000-0000-4000-8000-000000000005.ttf", + }; + expect(mergeImportedFonts([regular, italic, bold, foreign], [replacement])).toEqual([ + replacement, + italic, + bold, + foreign, + ]); + }); + it("keeps italic companions and selects the requested weight within its family", () => { + const faces = [foreign, bold, italic, regular]; + expect(importedFontVariant(faces, regular.family, 400)?.faces).toEqual([regular, italic]); + expect(importedFontVariant(faces, regular.family, 700)?.faces).toEqual([bold]); + expect(importedFontVariant(faces, regular.family, 500)?.faces).toEqual([regular, italic]); + }); + it("does not reuse a loaded alias after an italic companion is removed", () => { + const first = importedFontVariant([regular, italic], regular.family, 400)!; + const after = importedFontVariant([regular], regular.family, 400)!; + expect(after.alias).not.toBe(first.alias); + }); + it("preserves interface inheritance and independent reply, composer, and code choices", () => { + const faces = [regular, italic, bold, foreign]; + const loaded = new Set( + [400, 500, 700].map((weight) => importedFontVariant(faces, regular.family, weight)!.alias), + ); + const resolved = resolveMobileFonts( + { + ...DEFAULT_FONT_SELECTIONS, + interface: "serif", + reply: `imported:${regular.family}`, + code: "monospace", + }, + faces, + loaded, + "ios", + ); + expect(resolved.interface.regular).toBe("Georgia"); + expect(resolved.composer).toEqual(resolved.interface); + expect(resolved.reply.regular).toBe(importedFontVariant(faces, regular.family, 400)!.alias); + expect(resolved.reply.bold).toBe(importedFontVariant(faces, regular.family, 700)!.alias); + expect(resolved.code.regular).toBe("Menlo-Regular"); + }); + it("uses defaults while a font is unavailable and after it is removed", () => { + const choice = { ...DEFAULT_FONT_SELECTIONS, reply: `imported:${regular.family}` }; + const pending = resolveMobileFonts(choice, [regular], new Set(), "android"); + expect(pending.reply).toEqual(pending.interface); + const removed = resolveMobileFonts( + choice, + [], + new Set([importedFontVariant([regular], regular.family, 400)!.alias]), + "android", + ); + expect(removed.reply).toEqual(removed.interface); + }); +}); diff --git a/apps/mobile/src/lib/mobileFonts.ts b/apps/mobile/src/lib/mobileFonts.ts new file mode 100644 index 0000000000000..34380387d4e47 --- /dev/null +++ b/apps/mobile/src/lib/mobileFonts.ts @@ -0,0 +1,231 @@ +import { FONT_FAMILIES } from "./fontFamilies"; + +export const MOBILE_FONT_ROLES = ["interface", "reply", "composer", "code"] as const; +export type MobileFontRole = (typeof MOBILE_FONT_ROLES)[number]; +export type MobileFontSelections = Readonly>; +export interface ImportedFontFace { + readonly id: string; + readonly file: string; + readonly family: string; + readonly weight: number; + readonly italic: boolean; +} +export interface MobileFontFamily { + readonly regular: string; + readonly medium: string; + readonly bold: string; +} +export const DEFAULT_FONT_SELECTIONS: MobileFontSelections = { + interface: "", + reply: "", + composer: "", + code: "", +}; +export const BUILT_IN_FONT_OPTIONS = [ + { id: "", label: "Default" }, + { id: "system", label: "System sans" }, + { id: "serif", label: "Serif" }, + { id: "monospace", label: "Monospace" }, +] as const; + +export function normalizeFontSelections(value: unknown): MobileFontSelections { + const record = + typeof value === "object" && value !== null ? (value as Record) : {}; + return Object.fromEntries( + MOBILE_FONT_ROLES.map((role) => { + const candidate = record[role]; + return [ + role, + typeof candidate === "string" && + candidate.length <= 250 && + (BUILT_IN_FONT_OPTIONS.some((option) => option.id === candidate) || + candidate.startsWith("imported:")) + ? candidate + : "", + ]; + }), + ) as unknown as MobileFontSelections; +} + +export function normalizeImportedFonts(value: unknown): readonly ImportedFontFace[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + return value + .filter((face): face is ImportedFontFace => { + if ( + typeof face !== "object" || + face === null || + typeof face.id !== "string" || + !/^[a-f0-9-]{36}$/.test(face.id) || + seen.has(face.id) || + (face.file !== `${face.id}.ttf` && face.file !== `${face.id}.otf`) || + typeof face.family !== "string" || + face.family.trim().length === 0 || + face.family.length > 200 || + !Number.isInteger(face.weight) || + face.weight < 1 || + face.weight > 1000 || + typeof face.italic !== "boolean" + ) + return false; + seen.add(face.id); + return true; + }) + .slice(0, 64); +} + +export function mergeImportedFonts( + current: readonly ImportedFontFace[], + incoming: readonly ImportedFontFace[], +) { + const faces = [...current]; + for (const face of incoming) { + const index = faces.findIndex( + (old) => + old.family === face.family && old.weight === face.weight && old.italic === face.italic, + ); + if (index === -1) faces.push(face); + else faces[index] = face; + } + if (faces.length > 64) + throw new Error("You can import up to 64 font faces. Remove unused fonts first."); + return faces; +} + +export function importedFontFamilies(faces: readonly ImportedFontFace[]) { + return [...new Set(faces.map((face) => face.family))].sort((a, b) => a.localeCompare(b)); +} + +export function importedFontVariant( + faces: readonly ImportedFontFace[], + family: string, + weight: number, +) { + const candidates = faces.filter((face) => face.family === family); + const upright = candidates.filter((face) => !face.italic); + const chosen = [...(upright.length ? upright : candidates)].sort( + (a, b) => + Math.abs(a.weight - weight) - Math.abs(b.weight - weight) || + a.weight - b.weight || + a.id.localeCompare(b.id), + )[0]; + if (!chosen) return null; + const matching = candidates + .filter((face) => face.weight === chosen.weight) + .sort((a, b) => Number(a.italic) - Number(b.italic)); + const alias = `T3Font-${matching.map((face) => face.id).join("-")}`; + return { alias, faces: matching }; +} + +export function resolveMobileFonts( + selections: MobileFontSelections, + faces: readonly ImportedFontFace[], + loaded: ReadonlySet, + platform: string, +): Readonly> { + const defaults = FONT_FAMILIES; + const system = + platform === "ios" + ? { regular: "System", medium: "System", bold: "System" } + : { regular: "sans-serif", medium: "sans-serif-medium", bold: "sans-serif" }; + const serif = + platform === "ios" + ? { regular: "Georgia", medium: "Georgia", bold: "Georgia-Bold" } + : { regular: "serif", medium: "serif", bold: "serif" }; + const mono = + platform === "ios" + ? { regular: "Menlo-Regular", medium: "Menlo-Regular", bold: "Menlo-Bold" } + : { regular: "monospace", medium: "monospace", bold: "monospace" }; + const resolve = (selection: string, fallback: MobileFontFamily): MobileFontFamily => { + if (selection === "system") return system; + if (selection === "serif") return serif; + if (selection === "monospace") return mono; + if (!selection.startsWith("imported:")) return fallback; + const family = selection.slice(9); + const regular = importedFontVariant(faces, family, 400); + const medium = importedFontVariant(faces, family, 500); + const bold = importedFontVariant(faces, family, 700); + if ( + !regular || + !medium || + !bold || + ![regular, medium, bold].every((face) => loaded.has(face.alias)) + ) + return fallback; + return { regular: regular.alias, medium: medium.alias, bold: bold.alias }; + }; + const interfaceFont = resolve(selections.interface, defaults); + return { + interface: interfaceFont, + reply: resolve(selections.reply, interfaceFont), + composer: resolve(selections.composer, interfaceFont), + code: resolve(selections.code, mono), + }; +} + +/** Names and style metadata are needed to group separately imported faces without guessing from filenames. */ +export function readFontMetadata(bytes: Uint8Array): Omit { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const requireRange = (offset: number, length: number) => { + if (offset < 0 || length < 0 || offset + length > bytes.length) + throw new Error("This font file is incomplete or invalid."); + }; + const text = (offset: number, length: number, unicode = false) => { + requireRange(offset, length); + let value = ""; + for (let i = 0; i < length; i += unicode ? 2 : 1) { + if (unicode && i + 1 >= length) break; + value += String.fromCharCode(unicode ? view.getUint16(offset + i) : bytes[offset + i]!); + } + return value.replaceAll("\u0000", "").trim(); + }; + requireRange(0, 12); + if (view.getUint32(0) !== 0x00010000 && text(0, 4) !== "OTTO") + throw new Error("Choose a TrueType (.ttf) or OpenType (.otf) font file."); + const tables = new Map(); + const tableCount = view.getUint16(4); + requireRange(12, tableCount * 16); + for (let index = 0; index < tableCount; index++) { + const start = 12 + index * 16, + offset = view.getUint32(start + 8), + length = view.getUint32(start + 12); + requireRange(offset, length); + tables.set(text(start, 4), { offset, length }); + } + if (tables.has("fvar")) + throw new Error( + "Choose static font faces. Variable fonts are not supported by this import yet.", + ); + const names = tables.get("name"); + if (!names || names.length < 6) throw new Error("The font has no readable family name."); + const count = view.getUint16(names.offset + 2), + strings = names.offset + view.getUint16(names.offset + 4); + if (6 + count * 12 > names.length) throw new Error("The font name table is invalid."); + const values = new Map(); + for (let index = 0; index < count; index++) { + const start = names.offset + 6 + index * 12; + const platform = view.getUint16(start), + language = view.getUint16(start + 4), + id = view.getUint16(start + 6), + length = view.getUint16(start + 8), + offset = strings + view.getUint16(start + 10); + if (platform !== 0 && platform !== 3 && platform !== 1) continue; + if (offset < names.offset || offset + length > names.offset + names.length) + throw new Error("The font name table is invalid."); + const value = text(offset, length, platform !== 1); + const rank = platform === 3 && language === 0x409 ? 3 : platform === 0 ? 2 : 1; + if (value && rank > (values.get(id)?.rank ?? 0)) values.set(id, { value, rank }); + } + const family = values.get(16)?.value ?? values.get(1)?.value; + if (!family || family.length > 200) throw new Error("The font has no readable family name."); + const os2 = tables.get("OS/2"), + head = tables.get("head"); + const weight = os2 && os2.length >= 6 ? view.getUint16(os2.offset + 4) : 400; + const italic = + os2 && os2.length >= 64 + ? Boolean(view.getUint16(os2.offset + 62) & 1) + : head && head.length >= 46 + ? Boolean(view.getUint16(head.offset + 44) & 2) + : false; + return { family, weight: Math.max(1, Math.min(1000, weight)), italic }; +} diff --git a/apps/mobile/src/lib/useMobileNavigationTheme.ts b/apps/mobile/src/lib/useMobileNavigationTheme.ts index 54412e14cb2a8..580b1dfa6184d 100644 --- a/apps/mobile/src/lib/useMobileNavigationTheme.ts +++ b/apps/mobile/src/lib/useMobileNavigationTheme.ts @@ -5,12 +5,20 @@ import { useAppearancePreferences } from "../features/settings/appearance/Appear import { useUniwindTheme } from "./useUniwindTheme"; export function useMobileNavigationTheme(surface: "screen" | "sidebar" = "screen"): Theme { - const { themeAppearance: appearance } = useAppearancePreferences(); + const { themeAppearance: appearance, fonts, fontSelections } = useAppearancePreferences(); const variables = useUniwindTheme(); return useMemo(() => { const base = appearance === "dark" ? DarkTheme : DefaultTheme; return { ...base, + fonts: fontSelections.interface + ? { + regular: { fontFamily: fonts.interface.regular, fontWeight: "400" }, + medium: { fontFamily: fonts.interface.medium, fontWeight: "500" }, + bold: { fontFamily: fonts.interface.bold, fontWeight: "700" }, + heavy: { fontFamily: fonts.interface.bold, fontWeight: "800" }, + } + : base.fonts, colors: { ...base.colors, primary: variables["--color-primary-text"], @@ -24,5 +32,5 @@ export function useMobileNavigationTheme(surface: "screen" | "sidebar" = "screen notification: variables["--color-danger-foreground"], }, }; - }, [appearance, surface, variables]); + }, [appearance, fonts.interface, fontSelections.interface, surface, variables]); } diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index 86bf8021bdd0f..878daebe6b911 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -45,7 +45,7 @@ function enrichedStyle( dark: boolean, fontScale: number, ): MarkdownStyle { - const monospace = Platform.OS === "ios" ? "Menlo" : "monospace"; + const monospace = style.codeFontFamily ?? (Platform.OS === "ios" ? "Menlo" : "monospace"); // Keep the radius modest: iOS clips square table-cell borders without stroking the corner arcs. const block = { marginTop: 4, @@ -68,6 +68,8 @@ function enrichedStyle( ...body, color: style.strongColor, fontFamily: style.headingFontFamily, + fontWeight: + style.headingFontFamily === style.fontFamily ? ("bold" as const) : ("normal" as const), fontSize, lineHeight: fontSize * 1.35, marginTop: 8, @@ -90,7 +92,11 @@ function enrichedStyle( h4: heading(4), h5: heading(5), h6: heading(6), - strong: { color: style.strongColor, fontFamily: style.boldFontFamily, fontWeight: "normal" }, + strong: { + color: style.strongColor, + fontFamily: style.boldFontFamily, + fontWeight: style.boldFontFamily === style.fontFamily ? "bold" : "normal", + }, link: { color: style.linkColor, underline: false }, code: { fontFamily: monospace, diff --git a/apps/mobile/src/native/SelectableMarkdownText.types.ts b/apps/mobile/src/native/SelectableMarkdownText.types.ts index 63f8429c386fd..5f5371d4eac0e 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.types.ts +++ b/apps/mobile/src/native/SelectableMarkdownText.types.ts @@ -15,6 +15,7 @@ export interface NativeMarkdownTextStyle { readonly contextChipBorderColor?: string; readonly fontSize: number; readonly lineHeight: number; + readonly codeFontFamily?: string; readonly fontFamily: string; readonly headingFontFamily: string; readonly boldFontFamily: string; diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 437bcda4d012d..40f8768d7bcb8 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -1,3 +1,4 @@ +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste"; import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; @@ -26,8 +27,6 @@ import { import { contextChipIconUri, modelChipIcon, modelChipIconUri } from "../lib/contextChipIconSource"; import { useUniwindTheme } from "../lib/useUniwindTheme"; import { createNativeComposerTheme } from "../lib/nativeComposerTheme"; -import { FONT_FAMILIES } from "../lib/fontFamilies"; -import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { acknowledgeComposerNativeEvent, assumeComposerControlledState, @@ -176,9 +175,9 @@ export function ComposerEditor({ }); setConfirmedTokens({ value: props.value, tokens }); } - const bodyText = useScaledTextRole("body"); const theme = useUniwindTheme(); - const fontFamily = FONT_FAMILIES.regular; + const { fonts, composerFontSize } = useAppearancePreferences(); + const fontFamily = fonts.composer.regular; const skillLabels = useMemo(() => composerSkillLabels(skills), [skills]); const { modelsBySource, modelTokens } = useComposerModelTokens(props.value, models); @@ -355,12 +354,12 @@ export function ComposerEditor({ fontSize={ typeof resolvedTextStyle.fontSize === "number" ? resolvedTextStyle.fontSize - : bodyText.fontSize + : composerFontSize } lineHeight={ typeof resolvedTextStyle.lineHeight === "number" ? resolvedTextStyle.lineHeight - : bodyText.lineHeight + : Math.round((composerFontSize * 23) / 16) } contentInsetVertical={contentInsetVertical} editable={props.editable ?? true} diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 5aabd8e0d6071..112e740dde9a8 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -26,9 +26,7 @@ import { contextRecordFileName, } from "../lib/composerChipPresentation"; import { contextChipIconUri, modelChipIcon, modelChipIconUri } from "../lib/contextChipIconSource"; -import { MOBILE_TYPOGRAPHY } from "../lib/typography"; import { useNativePaste } from "../lib/useNativePaste"; -import { FONT_FAMILIES } from "../lib/fontFamilies"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useUniwindTheme } from "../lib/useUniwindTheme"; import { createNativeComposerTheme } from "../lib/nativeComposerTheme"; @@ -306,13 +304,13 @@ export function ComposerEditor({ }, [nativeEventSnapshotsRef], ); - const { systemColorsActive } = useAppearancePreferences(); + const { systemColorsActive, fonts, composerFontSize } = useAppearancePreferences(); const themeJson = JSON.stringify({ ...createNativeComposerTheme(theme), selection: Platform.OS === "android" || systemColorsActive ? theme["--color-focus"] : null, }); const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; - const regularFontFamily = FONT_FAMILIES.regular; + const regularFontFamily = fonts.composer.regular; return ( (null); const bodyText = useScaledTextRole("body"); - const fontFamily = FONT_FAMILIES.regular; + const { fonts, composerFontSize } = useAppearancePreferences(); + const fontFamily = fonts.composer.regular; const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); useImperativeHandle( @@ -58,6 +59,8 @@ export function ComposerEditor({ minHeight: 0, fontFamily, ...bodyText, + fontSize: composerFontSize, + lineHeight: Math.round((composerFontSize * 23) / 16), paddingVertical: contentInsetVertical, }, textStyle, diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 852d96c608520..41b8b39a1ba63 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -24,6 +24,13 @@ import * as MobileDatabase from "./mobile-database"; import * as MobileSecureStorage from "./mobile-secure-storage"; import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage"; +import { + normalizeFontSelections, + normalizeImportedFonts, + type MobileFontSelections, + type ImportedFontFace, +} from "../lib/mobileFonts"; + const PREFERENCES_KEY = "t3code.preferences"; const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; const encodeJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); @@ -99,6 +106,10 @@ export interface Preferences { readonly darkThemeId?: MobileThemeId; readonly themeMode?: MobileThemeMode; readonly androidNavigationStyle?: AndroidNavigationStyle; + readonly fontSelections?: MobileFontSelections; + readonly importedFonts?: readonly ImportedFontFace[]; + readonly replyFontSize?: number | null; + readonly composerFontSize?: number | null; readonly baseFontSize?: number; readonly terminalFontSize?: number | null; readonly markdownFontSize?: number; @@ -187,6 +198,10 @@ function sanitizePreferences(parsed: Preferences): Preferences { darkThemeId?: MobileThemeId; themeMode?: MobileThemeMode; androidNavigationStyle?: AndroidNavigationStyle; + fontSelections?: MobileFontSelections; + importedFonts?: readonly ImportedFontFace[]; + replyFontSize?: number | null; + composerFontSize?: number | null; baseFontSize?: number; terminalFontSize?: number | null; markdownFontSize?: number; @@ -271,6 +286,14 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (parsed.androidNavigationStyle === "one-ui" || parsed.androidNavigationStyle === "material") { preferences.androidNavigationStyle = parsed.androidNavigationStyle; } + if (parsed.fontSelections !== undefined) + preferences.fontSelections = normalizeFontSelections(parsed.fontSelections); + if (parsed.importedFonts !== undefined) + preferences.importedFonts = normalizeImportedFonts(parsed.importedFonts); + if (typeof parsed.replyFontSize === "number" || parsed.replyFontSize === null) + preferences.replyFontSize = parsed.replyFontSize; + if (typeof parsed.composerFontSize === "number" || parsed.composerFontSize === null) + preferences.composerFontSize = parsed.composerFontSize; if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize; if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { preferences.terminalFontSize = parsed.terminalFontSize; diff --git a/docs/user/appearance.md b/docs/user/appearance.md index b27173dd60ff9..a52833d077628 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -14,6 +14,16 @@ those modes. Customize these shortcuts under **Settings → Keybindings**. On mobile, open **Settings → Appearance**. Mobile has its own themes and text, code, and terminal preferences. It does not follow environment themes or defaults. +Under mobile **Appearance → Fonts**, choose fonts independently for the interface, replies, +composer, and code in messages or Markdown previews. Reply and composer sizes can follow Text size +or use their own size. Diffs and the terminal keep their existing fonts. + +To use your own font, choose **Import fonts from Files** and select static `.ttf` or `.otf` files. +Import matching regular, bold, and italic faces together; they are grouped by family name. Variable +font files and font collections are not supported by this import. Font files and selections stay on +this device and are not uploaded to connected environments. Removing an imported family resets any +choices using it to Default. + On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from your wallpaper. Selecting another theme replaces those colors. Like other themes, Material You can be selected separately for light and dark appearances. From f860fcef8d0057647f1702c5caf399402d44fd42 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 19 Oct 2026 20:03:48 -0400 Subject: [PATCH 02/11] fix(mobile): resolve imported composer fonts and isolate import failures --- .../t3composereditor/T3ComposerEditorView.kt | 10 +-- .../ios/T3ComposerEditorView.swift | 19 ++++- .../sections/FontAppearanceSection.tsx | 30 ++++++-- apps/mobile/src/lib/mobileFontFiles.test.ts | 77 ++++++++++++++++++- apps/mobile/src/lib/mobileFontFiles.ts | 28 +++++++ 5 files changed, 149 insertions(+), 15 deletions(-) diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 2dbbad891ae1f..f24b3f054820c 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -284,11 +284,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } fun setFontFamily(fontFamily: String) { - editor.typeface = if (fontFamily.contains("Mono", ignoreCase = true)) { - Typeface.MONOSPACE - } else { - ReactFontManager.getInstance().getTypeface(fontFamily, Typeface.NORMAL, context.assets) - } + editor.typeface = ReactFontManager.getInstance().getTypeface( + fontFamily, + Typeface.NORMAL, + context.assets, + ) applyLineHeight() editor.applyPlaceholder() } diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 96551d05bbb55..fa42f78871d3f 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -1140,9 +1140,21 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro return nil } + private func resolvedComposerFont() -> UIFont { + if let font = UIFont(name: fontFamily, size: fontSize) { + return font + } + // Expo registers aliases through the family-name lookup, not UIFont(name:). + for name in UIFont.fontNames(forFamilyName: fontFamily) { + if let font = UIFont(name: name, size: fontSize) { + return font + } + } + return UIFont.systemFont(ofSize: fontSize) + } + private func baseAttributes() -> [NSAttributedString.Key: Any] { - let font = UIFont(name: fontFamily, size: fontSize) - ?? UIFont.systemFont(ofSize: fontSize) + let font = resolvedComposerFont() let paragraph = NSMutableParagraphStyle() paragraph.minimumLineHeight = lineHeight paragraph.maximumLineHeight = lineHeight @@ -1154,8 +1166,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } private func applyTypography() { - let font = UIFont(name: fontFamily, size: fontSize) - ?? UIFont.systemFont(ofSize: fontSize) + let font = resolvedComposerFont() textView.font = font restoreBaseTypingAttributes() placeholderLabel.font = font diff --git a/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx index b881c13de1600..6b914bfd605bc 100644 --- a/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx @@ -11,11 +11,7 @@ import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; import { FontSizeSliderRow } from "../components/FontSizeSliderRow"; import { useAppearancePreferences } from "../AppearancePreferencesProvider"; import { updateMobilePreferencesAtom } from "../../../../state/preferences"; -import { - copyImportedFonts, - deleteImportedFontFiles, - loadImportedFontFiles, -} from "../../../../lib/mobileFontFiles"; +import { importFontFiles, deleteImportedFontFiles } from "../../../../lib/mobileFontFiles"; import { BUILT_IN_FONT_OPTIONS, DEFAULT_FONT_SELECTIONS, @@ -36,6 +32,30 @@ const ROLES: readonly { role: MobileFontRole; label: string }[] = [ { role: "code", label: "Chat code font" }, ]; +type SaveFontPreferences = (update: { + readonly transform: (current: Preferences) => Partial; +}) => Promise; + +async function importSelectedFonts( + importedFonts: readonly ImportedFontFace[], + savePreferences: SaveFontPreferences, +) { + const picked = await DocumentPicker.getDocumentAsync({ + type: "*/*", + multiple: true, + copyToCacheDirectory: true, + }); + if (picked.canceled) return; + await importFontFiles(picked.assets, importedFonts, async (copied) => { + const result = await savePreferences({ + transform: (current) => ({ + importedFonts: mergeImportedFonts(normalizeImportedFonts(current.importedFonts), copied), + }), + }); + return normalizeImportedFonts(result.importedFonts); + }); +} + export function FontAppearanceSection() { const { isReady, diff --git a/apps/mobile/src/lib/mobileFontFiles.test.ts b/apps/mobile/src/lib/mobileFontFiles.test.ts index 041ffd21ca2e3..4b55088a44f6b 100644 --- a/apps/mobile/src/lib/mobileFontFiles.test.ts +++ b/apps/mobile/src/lib/mobileFontFiles.test.ts @@ -46,7 +46,8 @@ vi.mock("expo-file-system", () => ({ }, })); -import { copyImportedFonts, loadImportedFontFiles } from "./mobileFontFiles"; +import { copyImportedFonts, importFontFiles, loadImportedFontFiles } from "./mobileFontFiles"; +import { mergeImportedFonts } from "./mobileFonts"; beforeEach(() => { mocks.files.clear(); @@ -56,6 +57,80 @@ beforeEach(() => { }); describe("mobile font import files", () => { + it("imports a new family even when an unrelated stored family is missing", async () => { + mocks.files.set("cache://old.ttf", fontFixture({ family: "Missing" })); + const current = await copyImportedFonts([{ uri: "cache://old.ttf", name: "old.ttf" }]); + mocks.files.delete(`document://custom-fonts/${current[0]!.file}`); + mocks.files.set("cache://new.ttf", fontFixture({ family: "Readable" })); + mocks.load.mockImplementation(async (definitions) => { + if ( + definitions[0].fontDefinitions.some((face: { path: string }) => !mocks.files.has(face.path)) + ) + throw new Error("File missing"); + }); + const save = vi.fn(async (copied) => mergeImportedFonts(current, copied)); + + const retained = await importFontFiles( + [{ uri: "cache://new.ttf", name: "new.ttf" }], + current, + save, + ); + + expect(save).toHaveBeenCalledOnce(); + expect(retained.map((face) => face.family)).toEqual(["Missing", "Readable"]); + expect(mocks.files.has(`document://custom-fonts/${retained[1]!.file}`)).toBe(true); + expect(mocks.deleted).toEqual([]); + }); + it("repairs a missing face with a replacement in the same family", async () => { + mocks.files.set("cache://old.ttf", fontFixture({ family: "Readable" })); + const current = await copyImportedFonts([{ uri: "cache://old.ttf", name: "old.ttf" }]); + mocks.files.delete(`document://custom-fonts/${current[0]!.file}`); + mocks.files.set("cache://replacement.ttf", fontFixture({ family: "Readable" })); + mocks.load.mockImplementation(async (definitions) => { + if ( + definitions[0].fontDefinitions.some((face: { path: string }) => !mocks.files.has(face.path)) + ) + throw new Error("File missing"); + }); + + const retained = await importFontFiles( + [{ uri: "cache://replacement.ttf", name: "replacement.ttf" }], + current, + async (copied) => mergeImportedFonts(current, copied), + ); + + expect(retained).toHaveLength(1); + expect(retained[0]!.id).not.toBe(current[0]!.id); + expect(mocks.files.has(`document://custom-fonts/${retained[0]!.file}`)).toBe(true); + }); + it("rolls back a new family that fails to load without saving it", async () => { + mocks.files.set("cache://new.ttf", fontFixture({ family: "Broken" })); + mocks.load.mockRejectedValue(new Error("Font rejected")); + const save = vi.fn(); + + await expect( + importFontFiles([{ uri: "cache://new.ttf", name: "new.ttf" }], [], save), + ).rejects.toThrow("Could not load: Broken"); + + expect(save).not.toHaveBeenCalled(); + expect([...mocks.files.keys()]).toEqual(["cache://new.ttf"]); + expect(mocks.deleted).toHaveLength(1); + }); + it("keeps existing files when saving a replacement fails", async () => { + mocks.files.set("cache://old.ttf", fontFixture({ family: "Readable" })); + const current = await copyImportedFonts([{ uri: "cache://old.ttf", name: "old.ttf" }]); + mocks.files.set("cache://new.ttf", fontFixture({ family: "Readable" })); + + await expect( + importFontFiles([{ uri: "cache://new.ttf", name: "new.ttf" }], current, async () => { + throw new Error("Storage failed"); + }), + ).rejects.toThrow("Storage failed"); + + expect([...mocks.files.keys()].filter((key) => key.startsWith("document://"))).toEqual([ + `document://custom-fonts/${current[0]!.file}`, + ]); + }); it("copies selected fonts into private storage and reads the family from the file", async () => { mocks.files.set( "cache://misleading-name.ttf", diff --git a/apps/mobile/src/lib/mobileFontFiles.ts b/apps/mobile/src/lib/mobileFontFiles.ts index e4e4d4ac87327..5e29ee79e1089 100644 --- a/apps/mobile/src/lib/mobileFontFiles.ts +++ b/apps/mobile/src/lib/mobileFontFiles.ts @@ -4,6 +4,7 @@ import { randomUUID } from "expo-crypto"; import { importedFontFamilies, importedFontVariant, + mergeImportedFonts, readFontMetadata, type ImportedFontFace, } from "./mobileFonts"; @@ -11,6 +12,33 @@ import { const directory = () => new Directory(Paths.document, "custom-fonts"); const fontFile = (face: ImportedFontFace) => new File(directory(), face.file); +export async function importFontFiles( + assets: readonly { readonly uri: string; readonly name: string }[], + current: readonly ImportedFontFace[], + save: (copied: readonly ImportedFontFace[]) => Promise, +) { + const copied = await copyImportedFonts(assets); + let saved = false; + try { + const next = mergeImportedFonts(current, copied); + const loaded = await loadImportedFontFiles(next); + const incomingFamilies = new Set(copied.map((face) => face.family)); + const unavailable = loaded.unavailable.filter((family) => incomingFamilies.has(family)); + if (unavailable.length) + throw new Error( + `Could not load: ${unavailable.join(", ")}. Try another static TTF or OTF font.`, + ); + const retained = await save(copied); + saved = true; + const retainedIds = new Set(retained.map((face) => face.id)); + deleteImportedFontFiles([...current, ...copied].filter((face) => !retainedIds.has(face.id))); + return retained; + } catch (error) { + if (!saved) deleteImportedFontFiles(copied); + throw error; + } +} + export async function copyImportedFonts( assets: readonly { readonly uri: string; readonly name: string }[], ) { From 7fc01d125c83aa0391d9a7ad3e666ef871a80e20 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 19 Oct 2026 23:33:02 -0400 Subject: [PATCH 03/11] feat(mobile): choose installed iOS font families instead of importing files --- apps/mobile/app.config.ts | 1 + .../ios/T3InstalledFonts.swift | 89 ++++++++ .../ios/T3NativeControlsModule.swift | 22 ++ .../AppearancePreferencesProvider.tsx | 75 ++++--- .../sections/FontAppearanceSection.tsx | 168 ++++------------ apps/mobile/src/lib/mobileFontFiles.test.ts | 190 ------------------ apps/mobile/src/lib/mobileFontFiles.ts | 107 ---------- .../src/lib/mobileFonts.test-fixtures.ts | 63 ------ apps/mobile/src/lib/mobileFonts.test.ts | 172 +++++----------- apps/mobile/src/lib/mobileFonts.ts | 168 +--------------- apps/mobile/src/native/installed-fonts.ts | 22 ++ .../src/persistence/mobile-preferences.ts | 11 +- 12 files changed, 275 insertions(+), 813 deletions(-) create mode 100644 apps/mobile/modules/t3-native-controls/ios/T3InstalledFonts.swift delete mode 100644 apps/mobile/src/lib/mobileFontFiles.test.ts delete mode 100644 apps/mobile/src/lib/mobileFontFiles.ts delete mode 100644 apps/mobile/src/lib/mobileFonts.test-fixtures.ts create mode 100644 apps/mobile/src/native/installed-fonts.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 97fe5cf977acc..3d2fad198cb13 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -264,6 +264,7 @@ const config: ExpoConfig = { `applinks:${HOSTED_APP_HOST}`, ], entitlements: { + "com.apple.developer.user-fonts": ["app-usage"], "keychain-access-groups": [`$(AppIdentifierPrefix)${variant.iosBundleIdentifier}`], }, infoPlist: { diff --git a/apps/mobile/modules/t3-native-controls/ios/T3InstalledFonts.swift b/apps/mobile/modules/t3-native-controls/ios/T3InstalledFonts.swift new file mode 100644 index 0000000000000..5c4c54f815c5c --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3InstalledFonts.swift @@ -0,0 +1,89 @@ +import CoreText +import UIKit + +@MainActor +enum T3InstalledFonts { + static func describe(_ font: UIFont, name: String? = nil) -> [String: String] { + func variant(_ weight: UIFont.Weight) -> String { + let descriptor = UIFontDescriptor(fontAttributes: [ + .family: font.familyName, + .traits: [UIFontDescriptor.TraitKey.weight: weight], + ]) + let candidate = UIFont(descriptor: descriptor, size: font.pointSize) + return candidate.familyName == font.familyName ? candidate.fontName : font.fontName + } + return [ + "name": name ?? font.fontName, + "label": font.familyName, + "regular": font.fontName, + "medium": variant(.medium), + "bold": variant(.bold), + ] + } + + static func resolve(_ names: [String], completion: @escaping ([[String: String]]) -> Void) { + guard !names.isEmpty else { completion([]); return } + // Provider fonts must be requested again for each process. Persist names, + // not file URLs: iOS owns the files and the picker grants family access. + let descriptors = names.map { CTFontDescriptorCreateWithAttributes([kCTFontNameAttribute: $0] as CFDictionary) } + CTFontManagerRequestFonts(descriptors as CFArray) { _ in + DispatchQueue.main.async { + completion(names.compactMap { name in + guard let font = UIFont(name: name, size: 16) else { return nil } + return describe(font, name: name) + }) + } + } + } +} + +@MainActor +final class T3FontPickerPresentation: NSObject, UIFontPickerViewControllerDelegate, UIAdaptivePresentationControllerDelegate { + private let completion: ([String: String]?) -> Void + private var picker: UIFontPickerViewController? + private var finished = false + + init(completion: @escaping ([String: String]?) -> Void) { + self.completion = completion + } + + func present(from presenter: UIViewController, selectedName: String?) { + let configuration = UIFontPickerViewController.Configuration() + configuration.includeFaces = false + let picker = UIFontPickerViewController(configuration: configuration) + if let selectedName, let font = UIFont(name: selectedName, size: 16) { + picker.selectedFontDescriptor = font.fontDescriptor + } + picker.delegate = self + self.picker = picker + presenter.present(picker, animated: true) + picker.presentationController?.delegate = self + } + + func fontPickerViewControllerDidPickFont(_ viewController: UIFontPickerViewController) { + guard let descriptor = viewController.selectedFontDescriptor else { dismiss(); return } + let result = T3InstalledFonts.describe(UIFont(descriptor: descriptor, size: 16)) + viewController.dismiss(animated: true) { self.finish(result) } + } + + func fontPickerViewControllerDidCancel(_ viewController: UIFontPickerViewController) { + dismiss() + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + finish(nil) + } + + func dismiss() { + guard let picker else { finish(nil); return } + picker.dismiss(animated: true) { self.finish(nil) } + } + + private func finish(_ result: [String: String]?) { + guard !finished else { return } + finished = true + picker?.delegate = nil + picker = nil + completion(result) + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index af67e80afebb4..9db0074810166 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -10,6 +10,7 @@ public final class T3NativeControlsModule: Module { private let presentationSources = T3PresentationSources() private var videoPresentation: T3NativeVideoPresentation? private var filePresentation: T3NativeFilePresentation? + private var fontPresentation: T3FontPickerPresentation? public func definition() -> ModuleDefinition { Events("videoPlaybackChange") @@ -63,6 +64,25 @@ public final class T3NativeControlsModule: Module { try T3DpopKeys.sign(privateKey: privateKey, signingInput: signingInput) } + AsyncFunction("pickInstalledFont") { (selectedName: String?, promise: Promise) in + guard self.fontPresentation == nil, + let presenter = self.appContext?.utilities?.currentViewController() + else { + promise.reject("ERR_FONT_PICKER", "Cannot present the font picker right now.") + return + } + let presentation = T3FontPickerPresentation { [weak self] result in + self?.fontPresentation = nil + promise.resolve(result) + } + self.fontPresentation = presentation + presentation.present(from: presenter, selectedName: selectedName) + }.runOnQueue(.main) + + AsyncFunction("resolveInstalledFonts") { (names: [String], promise: Promise) in + T3InstalledFonts.resolve(names) { promise.resolve($0) } + }.runOnQueue(.main) + AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in try self.presentVideo( url: url, @@ -102,9 +122,11 @@ public final class T3NativeControlsModule: Module { OnDestroy { let presentation = self.videoPresentation let file = self.filePresentation + let font = self.fontPresentation DispatchQueue.main.async { presentation?.dismiss() file?.dismiss() + font?.dismiss() } } diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index b30fa33a68030..dd8e08c66210e 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -21,12 +21,12 @@ import { appAtomRegistry } from "../../../state/atom-registry"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; import { normalizeFontSelections, - normalizeImportedFonts, resolveMobileFonts, type MobileFontRole, type MobileFontSelections, + type InstalledMobileFont, } from "../../../lib/mobileFonts"; -import { loadImportedFontFiles } from "../../../lib/mobileFontFiles"; +import { resolveInstalledFonts } from "../../../native/installed-fonts"; import { normalizeBaseFontSize } from "../../../lib/appearancePreferences"; import type { Preferences } from "../../../persistence/mobile-preferences"; import { @@ -56,7 +56,7 @@ import { interface AppearancePreferencesContextValue { readonly fonts: ReturnType; readonly fontSelections: MobileFontSelections; - readonly importedFonts: ReturnType; + readonly installedFonts: ReadonlyMap; readonly unavailableFonts: readonly string[]; readonly replyFontSize: number; readonly composerFontSize: number; @@ -246,38 +246,51 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN () => normalizeFontSelections(storedPreferences?.fontSelections), [storedPreferences?.fontSelections], ); - const importedFonts = useMemo( - () => normalizeImportedFonts(storedPreferences?.importedFonts), - [storedPreferences?.importedFonts], + const selectedFontNames = useMemo( + () => [ + ...new Set( + Object.values(fontSelections) + .filter((selection) => selection.startsWith("installed:")) + .map((selection) => selection.slice(10)), + ), + ], + [fontSelections], + ); + const [installedFonts, setInstalledFonts] = useState>( + new Map(), ); - const activeFontFiles = useMemo(() => { - const selected = new Set(Object.values(fontSelections)); - return importedFonts.filter((face) => selected.has(`imported:${face.family}`)); - }, [importedFonts, fontSelections]); - const libraryKey = activeFontFiles.map((face) => face.id).join(":"); - const [loadedFontFiles, setLoadedFontFiles] = useState<{ - key: string; - loaded: ReadonlySet; - unavailable: readonly string[]; - }>({ key: "", loaded: new Set(), unavailable: [] }); + const [unavailableFonts, setUnavailableFonts] = useState([]); useEffect(() => { let cancelled = false; - void loadImportedFontFiles(activeFontFiles).then((result) => { - if (!cancelled) setLoadedFontFiles({ key: libraryKey, ...result }); + let generation = 0; + const refresh = () => { + generation = generation + 1; + const request = generation; + void resolveInstalledFonts(selectedFontNames) + .then((resolved) => { + if (cancelled || request !== generation) return; + const available = new Map(resolved.map((font) => [font.name, font])); + setInstalledFonts(available); + setUnavailableFonts(selectedFontNames.filter((name) => !available.has(name))); + }) + .catch(() => { + if (cancelled || request !== generation) return; + setInstalledFonts(new Map()); + setUnavailableFonts(selectedFontNames); + }); + }; + refresh(); + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") refresh(); }); return () => { cancelled = true; + subscription.remove(); }; - }, [activeFontFiles, libraryKey]); + }, [selectedFontNames]); const fonts = useMemo( - () => - resolveMobileFonts( - fontSelections, - importedFonts, - loadedFontFiles.key === libraryKey ? loadedFontFiles.loaded : new Set(), - Platform.OS, - ), - [fontSelections, importedFonts, libraryKey, loadedFontFiles], + () => resolveMobileFonts(fontSelections, installedFonts, Platform.OS), + [fontSelections, installedFonts], ); const fontVariables = useMemo( () => ({ @@ -392,8 +405,8 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN appearance, fonts, fontSelections, - importedFonts, - unavailableFonts: loadedFontFiles.unavailable, + installedFonts, + unavailableFonts, replyFontSize, composerFontSize, customReplyFontSize, @@ -427,8 +440,8 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN appearance, fonts, fontSelections, - importedFonts, - loadedFontFiles.unavailable, + installedFonts, + unavailableFonts, replyFontSize, composerFontSize, customReplyFontSize, diff --git a/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx index 6b914bfd605bc..7fd07066bac9d 100644 --- a/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import { Alert, View } from "react-native"; import { useAtomSet } from "@effect/atom-react"; -import * as DocumentPicker from "expo-document-picker"; import { AppText as Text } from "../../../../components/ui/AppText"; import { ListRowLayout } from "../../../../components/ui/ListRowLayout"; import { Menu } from "../../../../components/ui/Menu"; @@ -11,17 +10,11 @@ import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; import { FontSizeSliderRow } from "../components/FontSizeSliderRow"; import { useAppearancePreferences } from "../AppearancePreferencesProvider"; import { updateMobilePreferencesAtom } from "../../../../state/preferences"; -import { importFontFiles, deleteImportedFontFiles } from "../../../../lib/mobileFontFiles"; +import { canPickInstalledFont, pickInstalledFont } from "../../../../native/installed-fonts"; import { BUILT_IN_FONT_OPTIONS, DEFAULT_FONT_SELECTIONS, - MOBILE_FONT_ROLES, - importedFontFamilies, - mergeImportedFonts, - normalizeFontSelections, - normalizeImportedFonts, type MobileFontRole, - type ImportedFontFace, } from "../../../../lib/mobileFonts"; import { MIN_BASE_FONT_SIZE, MAX_BASE_FONT_SIZE } from "../../../../lib/appearancePreferences"; @@ -32,35 +25,11 @@ const ROLES: readonly { role: MobileFontRole; label: string }[] = [ { role: "code", label: "Chat code font" }, ]; -type SaveFontPreferences = (update: { - readonly transform: (current: Preferences) => Partial; -}) => Promise; - -async function importSelectedFonts( - importedFonts: readonly ImportedFontFace[], - savePreferences: SaveFontPreferences, -) { - const picked = await DocumentPicker.getDocumentAsync({ - type: "*/*", - multiple: true, - copyToCacheDirectory: true, - }); - if (picked.canceled) return; - await importFontFiles(picked.assets, importedFonts, async (copied) => { - const result = await savePreferences({ - transform: (current) => ({ - importedFonts: mergeImportedFonts(normalizeImportedFonts(current.importedFonts), copied), - }), - }); - return normalizeImportedFonts(result.importedFonts); - }); -} - export function FontAppearanceSection() { const { isReady, fontSelections, - importedFonts, + installedFonts, unavailableFonts, fonts, replyFontSize, @@ -73,10 +42,12 @@ export function FontAppearanceSection() { } = useAppearancePreferences(); const savePreferences = useAtomSet(updateMobilePreferencesAtom, { mode: "promise" }); const [busy, setBusy] = useState(false); - const families = importedFontFamilies(importedFonts); const options = [ ...BUILT_IN_FONT_OPTIONS, - ...families.map((family) => ({ id: `imported:${family}`, label: family })), + ...[...installedFonts.values()].map((font) => ({ + id: `installed:${font.name}`, + label: font.label, + })), ]; const report = (error: unknown) => Alert.alert( @@ -84,64 +55,15 @@ export function FontAppearanceSection() { error instanceof Error ? error.message : "Could not update fonts. Please try again.", ); - const importFonts = async () => { - setBusy(true); - let copied: readonly ImportedFontFace[] = []; - let saved = false; - try { - const picked = await DocumentPicker.getDocumentAsync({ - type: "*/*", - multiple: true, - copyToCacheDirectory: true, - }); - if (picked.canceled) return; - copied = await copyImportedFonts(picked.assets); - const next = mergeImportedFonts(importedFonts, copied); - const loaded = await loadImportedFontFiles(next); - if (loaded.unavailable.length) - throw new Error( - `Could not load: ${loaded.unavailable.join(", ")}. Try another static TTF or OTF font.`, - ); - const result = await savePreferences({ - transform: (current) => ({ - importedFonts: mergeImportedFonts(normalizeImportedFonts(current.importedFonts), copied), - }), - }); - saved = true; - const retained = new Set(normalizeImportedFonts(result.importedFonts).map((face) => face.id)); - deleteImportedFontFiles( - [...importedFonts, ...copied].filter((face) => !retained.has(face.id)), - ); - } catch (error) { - if (!saved) deleteImportedFontFiles(copied); - report(error); - } finally { - setBusy(false); - } - }; - - const removeFamily = async (family: string) => { + const chooseInstalledFont = (role: MobileFontRole) => { setBusy(true); - try { - await savePreferences({ - transform: (current) => { - const selections = { ...normalizeFontSelections(current.fontSelections) }; - for (const role of MOBILE_FONT_ROLES) - if (selections[role] === `imported:${family}`) selections[role] = ""; - return { - fontSelections: selections, - importedFonts: normalizeImportedFonts(current.importedFonts).filter( - (face) => face.family !== family, - ), - }; - }, - }); - deleteImportedFontFiles(importedFonts.filter((face) => face.family === family)); - } catch (error) { - report(error); - } finally { - setBusy(false); - } + const selection = fontSelections[role]; + void pickInstalledFont(selection.startsWith("installed:") ? selection.slice(10) : null) + .then((font) => { + if (font) setFontSelection(role, `installed:${font.name}`); + }) + .catch(report) + .finally(() => setBusy(false)); }; return ( @@ -157,8 +79,8 @@ export function FontAppearanceSection() { A reply in your selected font. - Defaults follow the interface. Chat code uses a monospace default. These choices stay on - this device. + Defaults follow the interface. Choose an installed font to use its matching styles. These + choices stay on this device. {ROLES.map(({ role, label }) => ( @@ -166,14 +88,27 @@ export function FontAppearanceSection() { key={role} accessibilityLabel={label} accessibilityRole="button" - actions={options.map((option) => ({ - id: option.id || "default", - title: option.label, - state: fontSelections[role] === option.id ? "on" : "off", - attributes: { disabled: !isReady || busy }, - }))} + actions={[ + ...options.map((option) => ({ + id: option.id || "default", + title: option.label, + state: fontSelections[role] === option.id ? ("on" as const) : ("off" as const), + attributes: { disabled: !isReady || busy }, + })), + ...(canPickInstalledFont + ? [ + { + id: "choose-installed", + title: "Choose installed font…", + attributes: { disabled: !isReady || busy }, + }, + ] + : []), + ]} onPressAction={({ nativeEvent }) => - setFontSelection(role, nativeEvent.event === "default" ? "" : nativeEvent.event) + nativeEvent.event === "choose-installed" + ? chooseInstalledFont(role) + : setFontSelection(role, nativeEvent.event === "default" ? "" : nativeEvent.event) } > @@ -225,41 +160,14 @@ export function FontAppearanceSection() { onChange={setComposerFontSize} /> ) : null} - void importFonts()} - /> {unavailableFonts.length ? ( - Unavailable fonts: {unavailableFonts.join(", ")}. Using defaults; remove or reimport - these fonts. + Unavailable fonts: {unavailableFonts.join(", ")}. Using defaults. Choose an installed + font again or select Default. ) : null} - {families.map((family) => ( - - Alert.alert( - "Remove font?", - `${family} will be removed from this device. Any settings using it will return to Default.`, - [ - { text: "Cancel", style: "cancel" }, - { text: "Remove", style: "destructive", onPress: () => void removeFamily(family) }, - ], - ) - } - /> - ))} ({ - files: new Map(), - deleted: [] as string[], - load: vi.fn(), - nextId: 0, -})); -vi.mock("expo-crypto", () => ({ - randomUUID: () => `00000000-0000-4000-8000-${String(++mocks.nextId).padStart(12, "0")}`, -})); -vi.mock("expo-font", () => ({ isLoaded: () => false, loadAsync: mocks.load })); -vi.mock("expo-file-system", () => ({ - Paths: { document: "document://" }, - Directory: class { - uri: string; - constructor(base: string, part: string) { - this.uri = base + part; - } - create() {} - }, - File: class { - uri: string; - constructor(base: string | { uri: string }, file?: string) { - this.uri = typeof base === "string" ? base : base.uri + "/" + file; - } - get size() { - return mocks.files.get(this.uri)?.length ?? 0; - } - get exists() { - return mocks.files.has(this.uri); - } - async bytes() { - const data = mocks.files.get(this.uri); - if (!data) throw new Error("File missing"); - return data; - } - copy(target: { uri: string }) { - mocks.files.set(target.uri, mocks.files.get(this.uri)!); - } - delete() { - mocks.deleted.push(this.uri); - mocks.files.delete(this.uri); - } - }, -})); - -import { copyImportedFonts, importFontFiles, loadImportedFontFiles } from "./mobileFontFiles"; -import { mergeImportedFonts } from "./mobileFonts"; - -beforeEach(() => { - mocks.files.clear(); - mocks.deleted.length = 0; - mocks.nextId = 0; - mocks.load.mockReset().mockResolvedValue(undefined); -}); - -describe("mobile font import files", () => { - it("imports a new family even when an unrelated stored family is missing", async () => { - mocks.files.set("cache://old.ttf", fontFixture({ family: "Missing" })); - const current = await copyImportedFonts([{ uri: "cache://old.ttf", name: "old.ttf" }]); - mocks.files.delete(`document://custom-fonts/${current[0]!.file}`); - mocks.files.set("cache://new.ttf", fontFixture({ family: "Readable" })); - mocks.load.mockImplementation(async (definitions) => { - if ( - definitions[0].fontDefinitions.some((face: { path: string }) => !mocks.files.has(face.path)) - ) - throw new Error("File missing"); - }); - const save = vi.fn(async (copied) => mergeImportedFonts(current, copied)); - - const retained = await importFontFiles( - [{ uri: "cache://new.ttf", name: "new.ttf" }], - current, - save, - ); - - expect(save).toHaveBeenCalledOnce(); - expect(retained.map((face) => face.family)).toEqual(["Missing", "Readable"]); - expect(mocks.files.has(`document://custom-fonts/${retained[1]!.file}`)).toBe(true); - expect(mocks.deleted).toEqual([]); - }); - it("repairs a missing face with a replacement in the same family", async () => { - mocks.files.set("cache://old.ttf", fontFixture({ family: "Readable" })); - const current = await copyImportedFonts([{ uri: "cache://old.ttf", name: "old.ttf" }]); - mocks.files.delete(`document://custom-fonts/${current[0]!.file}`); - mocks.files.set("cache://replacement.ttf", fontFixture({ family: "Readable" })); - mocks.load.mockImplementation(async (definitions) => { - if ( - definitions[0].fontDefinitions.some((face: { path: string }) => !mocks.files.has(face.path)) - ) - throw new Error("File missing"); - }); - - const retained = await importFontFiles( - [{ uri: "cache://replacement.ttf", name: "replacement.ttf" }], - current, - async (copied) => mergeImportedFonts(current, copied), - ); - - expect(retained).toHaveLength(1); - expect(retained[0]!.id).not.toBe(current[0]!.id); - expect(mocks.files.has(`document://custom-fonts/${retained[0]!.file}`)).toBe(true); - }); - it("rolls back a new family that fails to load without saving it", async () => { - mocks.files.set("cache://new.ttf", fontFixture({ family: "Broken" })); - mocks.load.mockRejectedValue(new Error("Font rejected")); - const save = vi.fn(); - - await expect( - importFontFiles([{ uri: "cache://new.ttf", name: "new.ttf" }], [], save), - ).rejects.toThrow("Could not load: Broken"); - - expect(save).not.toHaveBeenCalled(); - expect([...mocks.files.keys()]).toEqual(["cache://new.ttf"]); - expect(mocks.deleted).toHaveLength(1); - }); - it("keeps existing files when saving a replacement fails", async () => { - mocks.files.set("cache://old.ttf", fontFixture({ family: "Readable" })); - const current = await copyImportedFonts([{ uri: "cache://old.ttf", name: "old.ttf" }]); - mocks.files.set("cache://new.ttf", fontFixture({ family: "Readable" })); - - await expect( - importFontFiles([{ uri: "cache://new.ttf", name: "new.ttf" }], current, async () => { - throw new Error("Storage failed"); - }), - ).rejects.toThrow("Storage failed"); - - expect([...mocks.files.keys()].filter((key) => key.startsWith("document://"))).toEqual([ - `document://custom-fonts/${current[0]!.file}`, - ]); - }); - it("copies selected fonts into private storage and reads the family from the file", async () => { - mocks.files.set( - "cache://misleading-name.ttf", - fontFixture({ typographicFamily: "Real Family", weight: 600 }), - ); - const [face] = await copyImportedFonts([ - { uri: "cache://misleading-name.ttf", name: "misleading-name.ttf" }, - ]); - expect(face).toMatchObject({ family: "Real Family", weight: 600, italic: false }); - expect(mocks.files.get(`document://custom-fonts/${face!.file}`)).toEqual( - mocks.files.get("cache://misleading-name.ttf"), - ); - expect(face).not.toHaveProperty("uri"); - }); - it("rolls back copied files if a later selected file is invalid", async () => { - mocks.files.set("cache://good.ttf", fontFixture()); - mocks.files.set("cache://bad.ttf", new Uint8Array(3)); - await expect( - copyImportedFonts([ - { uri: "cache://good.ttf", name: "good.ttf" }, - { uri: "cache://bad.ttf", name: "bad.ttf" }, - ]), - ).rejects.toThrow("incomplete"); - expect([...mocks.files.keys()].filter((key) => key.startsWith("document://"))).toEqual([]); - expect(mocks.deleted).toHaveLength(1); - }); - it("loads matching weights and italics together and isolates an unavailable family", async () => { - mocks.files.set("cache://regular.ttf", fontFixture({ family: "Readable" })); - mocks.files.set("cache://italic.ttf", fontFixture({ family: "Readable", italic: true })); - mocks.files.set("cache://missing.ttf", fontFixture({ family: "Unavailable" })); - const faces = await copyImportedFonts( - ["regular", "italic", "missing"].map((name) => ({ - uri: `cache://${name}.ttf`, - name: `${name}.ttf`, - })), - ); - const missingFile = faces[2]!.file; - mocks.load.mockImplementation(async (definitions) => { - if ( - definitions[0].fontDefinitions.some((face: { path: string }) => - face.path.endsWith(missingFile), - ) - ) - throw new Error("Font failed"); - }); - const result = await loadImportedFontFiles(faces); - expect(result.loaded.size).toBe(1); - expect(result.unavailable).toEqual(["Unavailable"]); - const readable = mocks.load.mock.calls.find( - ([definitions]) => definitions[0].fontDefinitions.length === 2, - )![0][0]; - expect(readable.fontDefinitions.map((face: { style: string }) => face.style)).toEqual([ - "normal", - "italic", - ]); - }); -}); diff --git a/apps/mobile/src/lib/mobileFontFiles.ts b/apps/mobile/src/lib/mobileFontFiles.ts deleted file mode 100644 index 5e29ee79e1089..0000000000000 --- a/apps/mobile/src/lib/mobileFontFiles.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { Directory, File, Paths } from "expo-file-system"; -import * as Font from "expo-font"; -import { randomUUID } from "expo-crypto"; -import { - importedFontFamilies, - importedFontVariant, - mergeImportedFonts, - readFontMetadata, - type ImportedFontFace, -} from "./mobileFonts"; - -const directory = () => new Directory(Paths.document, "custom-fonts"); -const fontFile = (face: ImportedFontFace) => new File(directory(), face.file); - -export async function importFontFiles( - assets: readonly { readonly uri: string; readonly name: string }[], - current: readonly ImportedFontFace[], - save: (copied: readonly ImportedFontFace[]) => Promise, -) { - const copied = await copyImportedFonts(assets); - let saved = false; - try { - const next = mergeImportedFonts(current, copied); - const loaded = await loadImportedFontFiles(next); - const incomingFamilies = new Set(copied.map((face) => face.family)); - const unavailable = loaded.unavailable.filter((family) => incomingFamilies.has(family)); - if (unavailable.length) - throw new Error( - `Could not load: ${unavailable.join(", ")}. Try another static TTF or OTF font.`, - ); - const retained = await save(copied); - saved = true; - const retainedIds = new Set(retained.map((face) => face.id)); - deleteImportedFontFiles([...current, ...copied].filter((face) => !retainedIds.has(face.id))); - return retained; - } catch (error) { - if (!saved) deleteImportedFontFiles(copied); - throw error; - } -} - -export async function copyImportedFonts( - assets: readonly { readonly uri: string; readonly name: string }[], -) { - if (assets.length > 64) throw new Error("Choose up to 64 font files at a time."); - directory().create({ intermediates: true, idempotent: true }); - const imported: ImportedFontFace[] = []; - try { - for (const asset of assets) { - const extension = /\.(ttf|otf)$/i.exec(asset.name)?.[1]?.toLowerCase(); - if (!extension) throw new Error(`${asset.name}: choose a .ttf or .otf font.`); - const source = new File(asset.uri); - if (source.size > 20 * 1024 * 1024) - throw new Error(`${asset.name}: fonts must be smaller than 20 MB.`); - const metadata = readFontMetadata(await source.bytes()); - const id = randomUUID(); - const face = { ...metadata, id, file: `${id}.${extension}` }; - imported.push(face); - source.copy(fontFile(face)); - } - return imported; - } catch (error) { - deleteImportedFontFiles(imported); - throw error; - } -} - -export function deleteImportedFontFiles(faces: readonly ImportedFontFace[]) { - for (const face of faces) { - const file = fontFile(face); - if (file.exists) file.delete(); - } -} - -export async function loadImportedFontFiles(faces: readonly ImportedFontFace[]) { - const variants = new Map>>(); - for (const family of importedFontFamilies(faces)) { - for (const weight of [400, 500, 700]) { - const variant = importedFontVariant(faces, family, weight); - if (variant) variants.set(variant.alias, variant); - } - } - const loaded = new Set(); - const unavailable: string[] = []; - await Promise.all( - [...variants.values()].map(async (variant) => { - try { - if (!Font.isLoaded(variant.alias)) - await Font.loadAsync([ - { - fontFamily: variant.alias, - fontDefinitions: variant.faces.map((face) => ({ - path: fontFile(face).uri, - weight: face.weight, - style: face.italic ? ("italic" as const) : ("normal" as const), - })), - }, - ]); - loaded.add(variant.alias); - } catch { - const family = variant.faces[0]?.family; - if (family && !unavailable.includes(family)) unavailable.push(family); - } - }), - ); - return { loaded, unavailable }; -} diff --git a/apps/mobile/src/lib/mobileFonts.test-fixtures.ts b/apps/mobile/src/lib/mobileFonts.test-fixtures.ts deleted file mode 100644 index 71b068220004d..0000000000000 --- a/apps/mobile/src/lib/mobileFonts.test-fixtures.ts +++ /dev/null @@ -1,63 +0,0 @@ -export function fontFixture( - options: { - family?: string; - typographicFamily?: string; - weight?: number; - italic?: boolean; - variable?: boolean; - } = {}, -) { - const records = [ - { id: 1, value: options.family ?? "Legacy Family" }, - ...(options.typographicFamily ? [{ id: 16, value: options.typographicFamily }] : []), - ]; - const strings = records.map((record) => { - const bytes = new Uint8Array(record.value.length * 2); - const view = new DataView(bytes.buffer); - [...record.value].forEach((_, index) => - view.setUint16(index * 2, record.value.charCodeAt(index)), - ); - return bytes; - }); - const nameLength = - 6 + records.length * 12 + strings.reduce((sum, string) => sum + string.length, 0); - const tables = [ - { tag: "name", length: nameLength }, - { tag: "OS/2", length: 64 }, - ...(options.variable ? [{ tag: "fvar", length: 16 }] : []), - ]; - const data = new Uint8Array( - 12 + tables.length * 16 + tables.reduce((sum, table) => sum + table.length, 0), - ); - const view = new DataView(data.buffer); - view.setUint32(0, 0x00010000); - view.setUint16(4, tables.length); - let offset = 12 + tables.length * 16; - for (const [index, table] of tables.entries()) { - const entry = 12 + index * 16; - for (let k = 0; k < 4; k++) data[entry + k] = table.tag.charCodeAt(k); - view.setUint32(entry + 8, offset); - view.setUint32(entry + 12, table.length); - if (table.tag === "name") { - view.setUint16(offset + 2, records.length); - view.setUint16(offset + 4, 6 + records.length * 12); - let relative = 0; - for (const [i, record] of records.entries()) { - const start = offset + 6 + i * 12; - view.setUint16(start, 3); - view.setUint16(start + 2, 1); - view.setUint16(start + 4, 0x409); - view.setUint16(start + 6, record.id); - view.setUint16(start + 8, strings[i]!.length); - view.setUint16(start + 10, relative); - data.set(strings[i]!, offset + 6 + records.length * 12 + relative); - relative += strings[i]!.length; - } - } else if (table.tag === "OS/2") { - view.setUint16(offset + 4, options.weight ?? 400); - view.setUint16(offset + 62, options.italic ? 1 : 0); - } - offset += table.length; - } - return data; -} diff --git a/apps/mobile/src/lib/mobileFonts.test.ts b/apps/mobile/src/lib/mobileFonts.test.ts index 90b00ff50f09d..60730a8de078a 100644 --- a/apps/mobile/src/lib/mobileFonts.test.ts +++ b/apps/mobile/src/lib/mobileFonts.test.ts @@ -1,149 +1,77 @@ -import { fontFixture } from "./mobileFonts.test-fixtures"; import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_FONT_SELECTIONS, - importedFontVariant, - mergeImportedFonts, normalizeFontSelections, - normalizeImportedFonts, - readFontMetadata, resolveMobileFonts, - type ImportedFontFace, } from "./mobileFonts"; -const regular: ImportedFontFace = { - id: "00000000-0000-4000-8000-000000000001", - file: "00000000-0000-4000-8000-000000000001.ttf", - family: "Example Serif", - weight: 400, - italic: false, +const serif = { + name: "ExampleSerif-Regular", + label: "Example Serif", + regular: "ExampleSerif-Regular", + medium: "ExampleSerif-Medium", + bold: "ExampleSerif-Bold", }; -const italic: ImportedFontFace = { - ...regular, - id: "00000000-0000-4000-8000-000000000002", - file: "00000000-0000-4000-8000-000000000002.ttf", - italic: true, -}; -const bold: ImportedFontFace = { - ...regular, - id: "00000000-0000-4000-8000-000000000003", - file: "00000000-0000-4000-8000-000000000003.ttf", - weight: 700, -}; -const foreign: ImportedFontFace = { - ...regular, - id: "00000000-0000-4000-8000-000000000004", - file: "00000000-0000-4000-8000-000000000004.ttf", - family: "Other Sans", - weight: 500, -}; - -describe("custom mobile font metadata", () => { - it("groups styles by the Unicode typographic family, not legacy names or filenames", () => { - expect( - readFontMetadata( - fontFixture({ - family: "Legacy Bold", - typographicFamily: "École Serif", - weight: 600, - italic: true, - }), - ), - ).toEqual({ family: "École Serif", weight: 600, italic: true }); - }); - it("falls back to the legacy family for static faces without typographic names", () => { - expect(readFontMetadata(fontFixture())).toEqual({ - family: "Legacy Family", - weight: 400, - italic: false, - }); - }); - it("explains unsupported variable files before they can be imported", () => { - expect(() => readFontMetadata(fontFixture({ variable: true }))).toThrow("static font faces"); - }); - it("rejects truncated headers and out-of-range name strings", () => { - expect(() => readFontMetadata(new Uint8Array(4))).toThrow("incomplete"); - const data = fontFixture(); - new DataView(data.buffer).setUint16(12 + 2 * 16 + 6 + 10, 65535); - expect(() => readFontMetadata(data)).toThrow("name table"); - }); -}); -describe("custom mobile font choices", () => { - it("does not inherit invalid choices or unsafe stored file paths", () => { +describe("installed mobile fonts", () => { + it("preserves installed font names and resets removed import choices and invalid preferences", () => { expect( normalizeFontSelections({ interface: "serif", - reply: 42, - composer: "unknown", - code: "imported:Example Serif", + reply: "installed:ExampleSerif-Regular", + composer: "imported:Old Family", + code: "installed:", }), - ).toEqual({ interface: "serif", reply: "", composer: "", code: "imported:Example Serif" }); + ).toEqual({ + interface: "serif", + reply: "installed:ExampleSerif-Regular", + composer: "", + code: "", + }); expect( - normalizeImportedFonts([ - regular, - regular, - { ...italic, file: "../private.ttf" }, - { ...bold, weight: Infinity }, - ]), - ).toEqual([regular]); + normalizeFontSelections({ reply: 42, interface: "installed:" + "x".repeat(251) }), + ).toEqual(DEFAULT_FONT_SELECTIONS); }); - it("replaces only the matching face when reimporting a weight and style", () => { - const replacement = { - ...regular, - id: "00000000-0000-4000-8000-000000000005", - file: "00000000-0000-4000-8000-000000000005.ttf", - }; - expect(mergeImportedFonts([regular, italic, bold, foreign], [replacement])).toEqual([ - replacement, - italic, - bold, - foreign, - ]); - }); - it("keeps italic companions and selects the requested weight within its family", () => { - const faces = [foreign, bold, italic, regular]; - expect(importedFontVariant(faces, regular.family, 400)?.faces).toEqual([regular, italic]); - expect(importedFontVariant(faces, regular.family, 700)?.faces).toEqual([bold]); - expect(importedFontVariant(faces, regular.family, 500)?.faces).toEqual([regular, italic]); - }); - it("does not reuse a loaded alias after an italic companion is removed", () => { - const first = importedFontVariant([regular, italic], regular.family, 400)!; - const after = importedFontVariant([regular], regular.family, 400)!; - expect(after.alias).not.toBe(first.alias); - }); - it("preserves interface inheritance and independent reply, composer, and code choices", () => { - const faces = [regular, italic, bold, foreign]; - const loaded = new Set( - [400, 500, 700].map((weight) => importedFontVariant(faces, regular.family, weight)!.alias), - ); - const resolved = resolveMobileFonts( + + it("uses the selected family's native weight faces independently for each role", () => { + const fonts = resolveMobileFonts( { ...DEFAULT_FONT_SELECTIONS, interface: "serif", - reply: `imported:${regular.family}`, + reply: `installed:${serif.name}`, code: "monospace", }, - faces, - loaded, + new Map([[serif.name, serif]]), "ios", ); - expect(resolved.interface.regular).toBe("Georgia"); - expect(resolved.composer).toEqual(resolved.interface); - expect(resolved.reply.regular).toBe(importedFontVariant(faces, regular.family, 400)!.alias); - expect(resolved.reply.bold).toBe(importedFontVariant(faces, regular.family, 700)!.alias); - expect(resolved.code.regular).toBe("Menlo-Regular"); + expect(fonts.interface.regular).toBe("Georgia"); + expect(fonts.composer).toEqual(fonts.interface); + expect(fonts.reply).toMatchObject({ + regular: serif.regular, + medium: serif.medium, + bold: serif.bold, + }); + expect(fonts.code.regular).toBe("Menlo-Regular"); }); - it("uses defaults while a font is unavailable and after it is removed", () => { - const choice = { ...DEFAULT_FONT_SELECTIONS, reply: `imported:${regular.family}` }; - const pending = resolveMobileFonts(choice, [regular], new Set(), "android"); - expect(pending.reply).toEqual(pending.interface); - const removed = resolveMobileFonts( - choice, - [], - new Set([importedFontVariant([regular], regular.family, 400)!.alias]), + + it("falls back while resolving or after an installed font is removed", () => { + const fonts = resolveMobileFonts( + { ...DEFAULT_FONT_SELECTIONS, reply: `installed:${serif.name}` }, + new Map(), + "ios", + ); + expect(fonts.reply).toEqual(fonts.interface); + }); + + it("keeps Android's system family choices without a file import dependency", () => { + const fonts = resolveMobileFonts( + { interface: "system", reply: "serif", composer: "monospace", code: "" }, + new Map(), "android", ); - expect(removed.reply).toEqual(removed.interface); + expect(fonts.interface.regular).toBe("sans-serif"); + expect(fonts.reply.regular).toBe("serif"); + expect(fonts.composer.regular).toBe("monospace"); + expect(fonts.code.regular).toBe("monospace"); }); }); diff --git a/apps/mobile/src/lib/mobileFonts.ts b/apps/mobile/src/lib/mobileFonts.ts index 34380387d4e47..6c41efb91af0e 100644 --- a/apps/mobile/src/lib/mobileFonts.ts +++ b/apps/mobile/src/lib/mobileFonts.ts @@ -3,18 +3,15 @@ import { FONT_FAMILIES } from "./fontFamilies"; export const MOBILE_FONT_ROLES = ["interface", "reply", "composer", "code"] as const; export type MobileFontRole = (typeof MOBILE_FONT_ROLES)[number]; export type MobileFontSelections = Readonly>; -export interface ImportedFontFace { - readonly id: string; - readonly file: string; - readonly family: string; - readonly weight: number; - readonly italic: boolean; -} export interface MobileFontFamily { readonly regular: string; readonly medium: string; readonly bold: string; } +export interface InstalledMobileFont extends MobileFontFamily { + readonly name: string; + readonly label: string; +} export const DEFAULT_FONT_SELECTIONS: MobileFontSelections = { interface: "", reply: "", @@ -39,7 +36,7 @@ export function normalizeFontSelections(value: unknown): MobileFontSelections { typeof candidate === "string" && candidate.length <= 250 && (BUILT_IN_FONT_OPTIONS.some((option) => option.id === candidate) || - candidate.startsWith("imported:")) + (candidate.startsWith("installed:") && candidate.slice(10).trim().length > 0)) ? candidate : "", ]; @@ -47,80 +44,9 @@ export function normalizeFontSelections(value: unknown): MobileFontSelections { ) as unknown as MobileFontSelections; } -export function normalizeImportedFonts(value: unknown): readonly ImportedFontFace[] { - if (!Array.isArray(value)) return []; - const seen = new Set(); - return value - .filter((face): face is ImportedFontFace => { - if ( - typeof face !== "object" || - face === null || - typeof face.id !== "string" || - !/^[a-f0-9-]{36}$/.test(face.id) || - seen.has(face.id) || - (face.file !== `${face.id}.ttf` && face.file !== `${face.id}.otf`) || - typeof face.family !== "string" || - face.family.trim().length === 0 || - face.family.length > 200 || - !Number.isInteger(face.weight) || - face.weight < 1 || - face.weight > 1000 || - typeof face.italic !== "boolean" - ) - return false; - seen.add(face.id); - return true; - }) - .slice(0, 64); -} - -export function mergeImportedFonts( - current: readonly ImportedFontFace[], - incoming: readonly ImportedFontFace[], -) { - const faces = [...current]; - for (const face of incoming) { - const index = faces.findIndex( - (old) => - old.family === face.family && old.weight === face.weight && old.italic === face.italic, - ); - if (index === -1) faces.push(face); - else faces[index] = face; - } - if (faces.length > 64) - throw new Error("You can import up to 64 font faces. Remove unused fonts first."); - return faces; -} - -export function importedFontFamilies(faces: readonly ImportedFontFace[]) { - return [...new Set(faces.map((face) => face.family))].sort((a, b) => a.localeCompare(b)); -} - -export function importedFontVariant( - faces: readonly ImportedFontFace[], - family: string, - weight: number, -) { - const candidates = faces.filter((face) => face.family === family); - const upright = candidates.filter((face) => !face.italic); - const chosen = [...(upright.length ? upright : candidates)].sort( - (a, b) => - Math.abs(a.weight - weight) - Math.abs(b.weight - weight) || - a.weight - b.weight || - a.id.localeCompare(b.id), - )[0]; - if (!chosen) return null; - const matching = candidates - .filter((face) => face.weight === chosen.weight) - .sort((a, b) => Number(a.italic) - Number(b.italic)); - const alias = `T3Font-${matching.map((face) => face.id).join("-")}`; - return { alias, faces: matching }; -} - export function resolveMobileFonts( selections: MobileFontSelections, - faces: readonly ImportedFontFace[], - loaded: ReadonlySet, + installed: ReadonlyMap, platform: string, ): Readonly> { const defaults = FONT_FAMILIES; @@ -140,19 +66,8 @@ export function resolveMobileFonts( if (selection === "system") return system; if (selection === "serif") return serif; if (selection === "monospace") return mono; - if (!selection.startsWith("imported:")) return fallback; - const family = selection.slice(9); - const regular = importedFontVariant(faces, family, 400); - const medium = importedFontVariant(faces, family, 500); - const bold = importedFontVariant(faces, family, 700); - if ( - !regular || - !medium || - !bold || - ![regular, medium, bold].every((face) => loaded.has(face.alias)) - ) - return fallback; - return { regular: regular.alias, medium: medium.alias, bold: bold.alias }; + if (!selection.startsWith("installed:")) return fallback; + return installed.get(selection.slice(10)) ?? fallback; }; const interfaceFont = resolve(selections.interface, defaults); return { @@ -162,70 +77,3 @@ export function resolveMobileFonts( code: resolve(selections.code, mono), }; } - -/** Names and style metadata are needed to group separately imported faces without guessing from filenames. */ -export function readFontMetadata(bytes: Uint8Array): Omit { - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - const requireRange = (offset: number, length: number) => { - if (offset < 0 || length < 0 || offset + length > bytes.length) - throw new Error("This font file is incomplete or invalid."); - }; - const text = (offset: number, length: number, unicode = false) => { - requireRange(offset, length); - let value = ""; - for (let i = 0; i < length; i += unicode ? 2 : 1) { - if (unicode && i + 1 >= length) break; - value += String.fromCharCode(unicode ? view.getUint16(offset + i) : bytes[offset + i]!); - } - return value.replaceAll("\u0000", "").trim(); - }; - requireRange(0, 12); - if (view.getUint32(0) !== 0x00010000 && text(0, 4) !== "OTTO") - throw new Error("Choose a TrueType (.ttf) or OpenType (.otf) font file."); - const tables = new Map(); - const tableCount = view.getUint16(4); - requireRange(12, tableCount * 16); - for (let index = 0; index < tableCount; index++) { - const start = 12 + index * 16, - offset = view.getUint32(start + 8), - length = view.getUint32(start + 12); - requireRange(offset, length); - tables.set(text(start, 4), { offset, length }); - } - if (tables.has("fvar")) - throw new Error( - "Choose static font faces. Variable fonts are not supported by this import yet.", - ); - const names = tables.get("name"); - if (!names || names.length < 6) throw new Error("The font has no readable family name."); - const count = view.getUint16(names.offset + 2), - strings = names.offset + view.getUint16(names.offset + 4); - if (6 + count * 12 > names.length) throw new Error("The font name table is invalid."); - const values = new Map(); - for (let index = 0; index < count; index++) { - const start = names.offset + 6 + index * 12; - const platform = view.getUint16(start), - language = view.getUint16(start + 4), - id = view.getUint16(start + 6), - length = view.getUint16(start + 8), - offset = strings + view.getUint16(start + 10); - if (platform !== 0 && platform !== 3 && platform !== 1) continue; - if (offset < names.offset || offset + length > names.offset + names.length) - throw new Error("The font name table is invalid."); - const value = text(offset, length, platform !== 1); - const rank = platform === 3 && language === 0x409 ? 3 : platform === 0 ? 2 : 1; - if (value && rank > (values.get(id)?.rank ?? 0)) values.set(id, { value, rank }); - } - const family = values.get(16)?.value ?? values.get(1)?.value; - if (!family || family.length > 200) throw new Error("The font has no readable family name."); - const os2 = tables.get("OS/2"), - head = tables.get("head"); - const weight = os2 && os2.length >= 6 ? view.getUint16(os2.offset + 4) : 400; - const italic = - os2 && os2.length >= 64 - ? Boolean(view.getUint16(os2.offset + 62) & 1) - : head && head.length >= 46 - ? Boolean(view.getUint16(head.offset + 44) & 2) - : false; - return { family, weight: Math.max(1, Math.min(1000, weight)), italic }; -} diff --git a/apps/mobile/src/native/installed-fonts.ts b/apps/mobile/src/native/installed-fonts.ts new file mode 100644 index 0000000000000..6af819090fa0f --- /dev/null +++ b/apps/mobile/src/native/installed-fonts.ts @@ -0,0 +1,22 @@ +import { requireOptionalNativeModule } from "expo"; +import { Platform } from "react-native"; +import type { InstalledMobileFont } from "../lib/mobileFonts"; + +const nativeControls = requireOptionalNativeModule<{ + pickInstalledFont?: (selectedName: string | null) => Promise; + resolveInstalledFonts?: (names: readonly string[]) => Promise; +}>("T3NativeControls"); + +export const canPickInstalledFont = + Platform.OS === "ios" && typeof nativeControls?.pickInstalledFont === "function"; + +export async function pickInstalledFont(selectedName: string | null) { + if (!canPickInstalledFont || !nativeControls?.pickInstalledFont) + throw new Error("Installed font selection requires an updated iOS app."); + return nativeControls.pickInstalledFont(selectedName); +} + +export async function resolveInstalledFonts(names: readonly string[]) { + if (!names.length || !nativeControls?.resolveInstalledFonts) return []; + return nativeControls.resolveInstalledFonts(names); +} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 41b8b39a1ba63..76e5d572527a5 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -24,12 +24,7 @@ import * as MobileDatabase from "./mobile-database"; import * as MobileSecureStorage from "./mobile-secure-storage"; import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage"; -import { - normalizeFontSelections, - normalizeImportedFonts, - type MobileFontSelections, - type ImportedFontFace, -} from "../lib/mobileFonts"; +import { normalizeFontSelections, type MobileFontSelections } from "../lib/mobileFonts"; const PREFERENCES_KEY = "t3code.preferences"; const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; @@ -107,7 +102,6 @@ export interface Preferences { readonly themeMode?: MobileThemeMode; readonly androidNavigationStyle?: AndroidNavigationStyle; readonly fontSelections?: MobileFontSelections; - readonly importedFonts?: readonly ImportedFontFace[]; readonly replyFontSize?: number | null; readonly composerFontSize?: number | null; readonly baseFontSize?: number; @@ -199,7 +193,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { themeMode?: MobileThemeMode; androidNavigationStyle?: AndroidNavigationStyle; fontSelections?: MobileFontSelections; - importedFonts?: readonly ImportedFontFace[]; replyFontSize?: number | null; composerFontSize?: number | null; baseFontSize?: number; @@ -288,8 +281,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { } if (parsed.fontSelections !== undefined) preferences.fontSelections = normalizeFontSelections(parsed.fontSelections); - if (parsed.importedFonts !== undefined) - preferences.importedFonts = normalizeImportedFonts(parsed.importedFonts); if (typeof parsed.replyFontSize === "number" || parsed.replyFontSize === null) preferences.replyFontSize = parsed.replyFontSize; if (typeof parsed.composerFontSize === "number" || parsed.composerFontSize === null) From 4af0add73f5c1bb95dea47c40bb8eb2f546ae501 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 19 Oct 2026 23:37:19 -0400 Subject: [PATCH 04/11] fix(mobile): isolate native font picker callbacks on the main actor --- .../ios/T3NativeControlsModule.swift | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 9db0074810166..a2f9739e5b07e 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -65,22 +65,26 @@ public final class T3NativeControlsModule: Module { } AsyncFunction("pickInstalledFont") { (selectedName: String?, promise: Promise) in - guard self.fontPresentation == nil, - let presenter = self.appContext?.utilities?.currentViewController() - else { - promise.reject("ERR_FONT_PICKER", "Cannot present the font picker right now.") - return - } - let presentation = T3FontPickerPresentation { [weak self] result in - self?.fontPresentation = nil - promise.resolve(result) + MainActor.assumeIsolated { + guard self.fontPresentation == nil, + let presenter = self.appContext?.utilities?.currentViewController() + else { + promise.reject("ERR_FONT_PICKER", "Cannot present the font picker right now.") + return + } + let presentation = T3FontPickerPresentation { [weak self] result in + self?.fontPresentation = nil + promise.resolve(result) + } + self.fontPresentation = presentation + presentation.present(from: presenter, selectedName: selectedName) } - self.fontPresentation = presentation - presentation.present(from: presenter, selectedName: selectedName) }.runOnQueue(.main) AsyncFunction("resolveInstalledFonts") { (names: [String], promise: Promise) in - T3InstalledFonts.resolve(names) { promise.resolve($0) } + MainActor.assumeIsolated { + T3InstalledFonts.resolve(names) { promise.resolve($0) } + } }.runOnQueue(.main) AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in From 4e73c4bd22429b9a193bf4adc8a987a0f3540b7f Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 20 Oct 2026 00:33:34 -0400 Subject: [PATCH 05/11] feat(mobile): choose reply emphasis weight per font --- .../ios/T3InstalledFonts.swift | 21 ++++++-- .../features/files/FileMarkdownPreview.tsx | 4 +- .../AppearancePreferencesProvider.tsx | 29 +++++++++++ .../sections/FontAppearanceSection.tsx | 50 +++++++++++++++++-- .../src/features/threads/ThreadFeed.tsx | 9 ++-- apps/mobile/src/lib/fontFamilies.ts | 1 + apps/mobile/src/lib/mobileFonts.test.ts | 43 ++++++++++++++++ apps/mobile/src/lib/mobileFonts.ts | 39 ++++++++++++++- .../src/persistence/mobile-preferences.ts | 11 +++- docs/user/appearance.md | 14 ++++-- 10 files changed, 199 insertions(+), 22 deletions(-) diff --git a/apps/mobile/modules/t3-native-controls/ios/T3InstalledFonts.swift b/apps/mobile/modules/t3-native-controls/ios/T3InstalledFonts.swift index 5c4c54f815c5c..e1a47ba4e7f3d 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3InstalledFonts.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3InstalledFonts.swift @@ -3,7 +3,7 @@ import UIKit @MainActor enum T3InstalledFonts { - static func describe(_ font: UIFont, name: String? = nil) -> [String: String] { + static func describe(_ font: UIFont, name: String? = nil) -> [String: Any] { func variant(_ weight: UIFont.Weight) -> String { let descriptor = UIFontDescriptor(fontAttributes: [ .family: font.familyName, @@ -12,16 +12,27 @@ enum T3InstalledFonts { let candidate = UIFont(descriptor: descriptor, size: font.pointSize) return candidate.familyName == font.familyName ? candidate.fontName : font.fontName } + // CoreText reports medium as 0.20 while UIKit requests 0.23. + var emphasisFaces: [String: String] = [:] + for (key, weight) in [("500", UIFont.Weight.medium), ("600", .semibold), ("700", .bold)] { + let name = variant(weight) + guard let candidate = UIFont(name: name, size: font.pointSize), + let traits = candidate.fontDescriptor.object(forKey: .traits) as? [UIFontDescriptor.TraitKey: Any], + let actual = traits[.weight] as? NSNumber, + abs(actual.doubleValue - Double(weight.rawValue)) < 0.04 else { continue } + emphasisFaces[key] = name + } return [ "name": name ?? font.fontName, "label": font.familyName, "regular": font.fontName, "medium": variant(.medium), "bold": variant(.bold), + "emphasisFaces": emphasisFaces, ] } - static func resolve(_ names: [String], completion: @escaping ([[String: String]]) -> Void) { + static func resolve(_ names: [String], completion: @escaping ([[String: Any]]) -> Void) { guard !names.isEmpty else { completion([]); return } // Provider fonts must be requested again for each process. Persist names, // not file URLs: iOS owns the files and the picker grants family access. @@ -39,11 +50,11 @@ enum T3InstalledFonts { @MainActor final class T3FontPickerPresentation: NSObject, UIFontPickerViewControllerDelegate, UIAdaptivePresentationControllerDelegate { - private let completion: ([String: String]?) -> Void + private let completion: ([String: Any]?) -> Void private var picker: UIFontPickerViewController? private var finished = false - init(completion: @escaping ([String: String]?) -> Void) { + init(completion: @escaping ([String: Any]?) -> Void) { self.completion = completion } @@ -79,7 +90,7 @@ final class T3FontPickerPresentation: NSObject, UIFontPickerViewControllerDelega picker.dismiss(animated: true) { self.finish(nil) } } - private func finish(_ result: [String: String]?) { + private func finish(_ result: [String: Any]?) { guard !finished else { return } finished = true picker?.delegate = nil diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index 6c3fd8cfbba4f..403231df10b8a 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -38,7 +38,7 @@ interface MarkdownPreviewStyles { } export function useMarkdownPreviewStyles(): MarkdownPreviewStyles { - const { fonts, replyFontSize } = useAppearancePreferences(); + const { fonts, replyFontSize, replyEmphasisFontFamily } = useAppearancePreferences(); const nativeMarkdownTypography = resolveNativeMarkdownTypography(replyFontSize); const theme = useUniwindTheme(); const body = theme["--color-md-body"]; @@ -70,7 +70,7 @@ export function useMarkdownPreviewStyles(): MarkdownPreviewStyles { codeFontFamily: fonts.code.regular, fontFamily: regularFontFamily, headingFontFamily: boldFontFamily, - boldFontFamily, + boldFontFamily: replyEmphasisFontFamily, }, }; } diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index dd8e08c66210e..e2f5a29862e44 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -21,6 +21,9 @@ import { appAtomRegistry } from "../../../state/atom-registry"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; import { normalizeFontSelections, + normalizeReplyEmphasisWeights, + resolveReplyEmphasis, + type ReplyEmphasisWeight, resolveMobileFonts, type MobileFontRole, type MobileFontSelections, @@ -59,6 +62,9 @@ interface AppearancePreferencesContextValue { readonly installedFonts: ReadonlyMap; readonly unavailableFonts: readonly string[]; readonly replyFontSize: number; + readonly replyEmphasisWeight: ReplyEmphasisWeight | undefined; + readonly replyEmphasisFontFamily: string; + readonly setReplyEmphasisWeight: (weight: ReplyEmphasisWeight | undefined) => void; readonly composerFontSize: number; readonly customReplyFontSize: boolean; readonly customComposerFontSize: boolean; @@ -292,6 +298,10 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN () => resolveMobileFonts(fontSelections, installedFonts, Platform.OS), [fontSelections, installedFonts], ); + const replyEmphasisWeight = normalizeReplyEmphasisWeights( + storedPreferences?.replyEmphasisWeights, + )[fonts.reply.regular]; + const replyEmphasisFontFamily = resolveReplyEmphasis(fonts.reply, replyEmphasisWeight); const fontVariables = useMemo( () => ({ ...themeVariables, @@ -389,6 +399,19 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN }, [savePreferences], ); + const setReplyEmphasisWeight = useCallback( + (weight: ReplyEmphasisWeight | undefined) => { + savePreferences({ + transform: (current) => { + const next = { ...normalizeReplyEmphasisWeights(current.replyEmphasisWeights) }; + if (weight === undefined) delete next[fonts.reply.regular]; + else next[fonts.reply.regular] = weight; + return { replyEmphasisWeights: next }; + }, + }); + }, + [fonts.reply.regular, savePreferences], + ); const setReplyFontSize = useCallback( (size: number | null) => updatePreferences({ replyFontSize: size === null ? null : normalizeBaseFontSize(size) }), @@ -408,6 +431,9 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN installedFonts, unavailableFonts, replyFontSize, + replyEmphasisWeight, + replyEmphasisFontFamily, + setReplyEmphasisWeight, composerFontSize, customReplyFontSize, customComposerFontSize, @@ -443,6 +469,9 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN installedFonts, unavailableFonts, replyFontSize, + replyEmphasisWeight, + replyEmphasisFontFamily, + setReplyEmphasisWeight, composerFontSize, customReplyFontSize, customComposerFontSize, diff --git a/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx index 7fd07066bac9d..1eb18bf212f4f 100644 --- a/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx @@ -13,6 +13,7 @@ import { updateMobilePreferencesAtom } from "../../../../state/preferences"; import { canPickInstalledFont, pickInstalledFont } from "../../../../native/installed-fonts"; import { BUILT_IN_FONT_OPTIONS, + REPLY_EMPHASIS_OPTIONS, DEFAULT_FONT_SELECTIONS, type MobileFontRole, } from "../../../../lib/mobileFonts"; @@ -33,6 +34,9 @@ export function FontAppearanceSection() { unavailableFonts, fonts, replyFontSize, + replyEmphasisWeight, + replyEmphasisFontFamily, + setReplyEmphasisWeight, composerFontSize, customReplyFontSize, customComposerFontSize, @@ -76,11 +80,11 @@ export function FontAppearanceSection() { lineHeight: Math.round(replyFontSize * 1.45), }} > - A reply in your selected font. + A reply in your selected font.{" "} + Emphasized text. - Defaults follow the interface. Choose an installed font to use its matching styles. These - choices stay on this device. + Defaults follow the interface. These choices stay on this device. {ROLES.map(({ role, label }) => ( @@ -122,6 +126,45 @@ export function FontAppearanceSection() { ))} + ({ + id: option.id, + title: option.label, + state: replyEmphasisWeight === option.id ? ("on" as const) : ("off" as const), + attributes: { disabled: !isReady || busy || !fonts.reply.emphasisFaces?.[option.id] }, + })), + ]} + onPressAction={({ nativeEvent }) => + setReplyEmphasisWeight( + REPLY_EMPHASIS_OPTIONS.find((option) => option.id === nativeEvent.event)?.id, + ) + } + > + + option.id === replyEmphasisWeight)?.label ?? + "Default" + } + /> + + + + + Emphasis is remembered for this reply font. Unavailable weights are disabled. + + void savePreferences({ fontSelections: DEFAULT_FONT_SELECTIONS, + replyEmphasisWeights: {}, replyFontSize: null, composerFontSize: null, }).catch(report) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 32557dd49297c..1decab29c74e8 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1259,7 +1259,7 @@ function MarkdownDetailsFold(props: { } function useMarkdownStyles(): MarkdownStyleSets { - const { appearance, fonts, replyFontSize } = useAppearancePreferences(); + const { appearance, fonts, replyFontSize, replyEmphasisFontFamily } = useAppearancePreferences(); const replyTypography = useMemo( () => resolveNativeMarkdownTypography(replyFontSize), [replyFontSize], @@ -1335,12 +1335,13 @@ function useMarkdownStyles(): MarkdownStyleSets { codeFontFamily: fonts.code.regular, fontFamily: fonts.reply.regular, headingFontFamily: fonts.reply.bold, - boldFontFamily: fonts.reply.bold, + boldFontFamily: replyEmphasisFontFamily, }, }, }; }, [ fonts.reply, + replyEmphasisFontFamily, fonts.code.regular, replyTypography, boldFontFamily, @@ -2740,12 +2741,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const copyFeedbackTimeoutRef = useRef | null>(null); const headerMaterialVisibleRef = useRef(false); const { width: windowWidth, fontScale } = useWindowDimensions(); - const { appearance, fonts, replyFontSize } = useAppearancePreferences(); + const { appearance, fonts, replyFontSize, replyEmphasisFontFamily } = useAppearancePreferences(); const workRowSizing = deriveThreadWorkLogSizing({ baseFontSize: appearance.baseFontSize, fontScale, }); - const textLayoutKey = `${workRowSizing.textSizeKey}:${replyFontSize}:${fonts.interface.regular}:${fonts.interface.bold}:${fonts.reply.regular}:${fonts.reply.bold}:${fonts.code.regular}`; + const textLayoutKey = `${workRowSizing.textSizeKey}:${replyFontSize}:${fonts.interface.regular}:${fonts.interface.bold}:${fonts.reply.regular}:${fonts.reply.bold}:${replyEmphasisFontFamily}:${fonts.code.regular}`; const settledPaneWidth = useWorkspaceSettledContentWidth(); const splitLayout = (props.layoutVariant ?? "compact") !== "compact"; const [measuredViewportWidth, setViewportWidth] = useState(() => (splitLayout ? 0 : windowWidth)); diff --git a/apps/mobile/src/lib/fontFamilies.ts b/apps/mobile/src/lib/fontFamilies.ts index 38602ec7c9892..4c7a237df3767 100644 --- a/apps/mobile/src/lib/fontFamilies.ts +++ b/apps/mobile/src/lib/fontFamilies.ts @@ -2,4 +2,5 @@ export const FONT_FAMILIES = { regular: "DMSans-Regular", medium: "DMSans-Medium", bold: "DMSans-Bold", + emphasisFaces: { "500": "DMSans-Medium", "700": "DMSans-Bold" }, } as const; diff --git a/apps/mobile/src/lib/mobileFonts.test.ts b/apps/mobile/src/lib/mobileFonts.test.ts index 60730a8de078a..de41baf0469fe 100644 --- a/apps/mobile/src/lib/mobileFonts.test.ts +++ b/apps/mobile/src/lib/mobileFonts.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_FONT_SELECTIONS, normalizeFontSelections, + normalizeReplyEmphasisWeights, + resolveReplyEmphasis, resolveMobileFonts, } from "./mobileFonts"; @@ -11,6 +13,11 @@ const serif = { regular: "ExampleSerif-Regular", medium: "ExampleSerif-Medium", bold: "ExampleSerif-Bold", + emphasisFaces: { + "500": "ExampleSerif-Medium", + "600": "ExampleSerif-Semibold", + "700": "ExampleSerif-Bold", + }, }; describe("installed mobile fonts", () => { @@ -75,3 +82,39 @@ describe("installed mobile fonts", () => { expect(fonts.code.regular).toBe("monospace"); }); }); + +describe("reply emphasis", () => { + it("preserves the default bold face and only changes emphasis for a selected weight", () => { + const standard = resolveMobileFonts(DEFAULT_FONT_SELECTIONS, new Map(), "ios"); + expect(resolveReplyEmphasis(standard.reply)).toBe("DMSans-Bold"); + expect(resolveReplyEmphasis(standard.reply, "500")).toBe("DMSans-Medium"); + }); + + it("selects a real installed semibold face and falls back when that weight is unavailable", () => { + expect(resolveReplyEmphasis(serif, "600")).toBe("ExampleSerif-Semibold"); + expect( + resolveReplyEmphasis( + { + regular: "Example-Regular", + medium: "Example-Regular", + bold: "Example-Bold", + emphasisFaces: { "700": "Example-Bold" }, + }, + "600", + ), + ).toBe("Example-Bold"); + }); + + it("remembers different weights per font and drops invalid saved values", () => { + const saved = normalizeReplyEmphasisWeights({ + "FirstSerif-Regular": "700", + "SecondSerif-Regular": "600", + invalid: 600, + bad: "900", + "": "500", + }); + expect(saved).toEqual({ "FirstSerif-Regular": "700", "SecondSerif-Regular": "600" }); + expect(normalizeReplyEmphasisWeights(null)).toEqual({}); + expect(resolveReplyEmphasis(serif, saved["SecondSerif-Regular"])).toBe("ExampleSerif-Semibold"); + }); +}); diff --git a/apps/mobile/src/lib/mobileFonts.ts b/apps/mobile/src/lib/mobileFonts.ts index 6c41efb91af0e..6a3f10fa1abb2 100644 --- a/apps/mobile/src/lib/mobileFonts.ts +++ b/apps/mobile/src/lib/mobileFonts.ts @@ -7,6 +7,7 @@ export interface MobileFontFamily { readonly regular: string; readonly medium: string; readonly bold: string; + readonly emphasisFaces?: Readonly>>; } export interface InstalledMobileFont extends MobileFontFamily { readonly name: string; @@ -56,11 +57,21 @@ export function resolveMobileFonts( : { regular: "sans-serif", medium: "sans-serif-medium", bold: "sans-serif" }; const serif = platform === "ios" - ? { regular: "Georgia", medium: "Georgia", bold: "Georgia-Bold" } + ? { + regular: "Georgia", + medium: "Georgia", + bold: "Georgia-Bold", + emphasisFaces: { "700": "Georgia-Bold" }, + } : { regular: "serif", medium: "serif", bold: "serif" }; const mono = platform === "ios" - ? { regular: "Menlo-Regular", medium: "Menlo-Regular", bold: "Menlo-Bold" } + ? { + regular: "Menlo-Regular", + medium: "Menlo-Regular", + bold: "Menlo-Bold", + emphasisFaces: { "700": "Menlo-Bold" }, + } : { regular: "monospace", medium: "monospace", bold: "monospace" }; const resolve = (selection: string, fallback: MobileFontFamily): MobileFontFamily => { if (selection === "system") return system; @@ -77,3 +88,27 @@ export function resolveMobileFonts( code: resolve(selections.code, mono), }; } + +export const REPLY_EMPHASIS_OPTIONS = [ + { id: "500", label: "Medium" }, + { id: "600", label: "Semibold" }, + { id: "700", label: "Bold" }, +] as const; +export type ReplyEmphasisWeight = (typeof REPLY_EMPHASIS_OPTIONS)[number]["id"]; +export type ReplyEmphasisWeights = Readonly>; + +export function normalizeReplyEmphasisWeights(value: unknown): ReplyEmphasisWeights { + if (typeof value !== "object" || value === null) return {}; + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, ReplyEmphasisWeight] => + entry[0].length > 0 && + entry[0].length <= 250 && + REPLY_EMPHASIS_OPTIONS.some((option) => option.id === entry[1]), + ), + ); +} + +export function resolveReplyEmphasis(font: MobileFontFamily, weight?: ReplyEmphasisWeight) { + return (weight && font.emphasisFaces?.[weight]) || font.bold; +} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 76e5d572527a5..d92be98d692df 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -24,7 +24,12 @@ import * as MobileDatabase from "./mobile-database"; import * as MobileSecureStorage from "./mobile-secure-storage"; import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage"; -import { normalizeFontSelections, type MobileFontSelections } from "../lib/mobileFonts"; +import { + normalizeFontSelections, + normalizeReplyEmphasisWeights, + type MobileFontSelections, + type ReplyEmphasisWeights, +} from "../lib/mobileFonts"; const PREFERENCES_KEY = "t3code.preferences"; const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; @@ -103,6 +108,7 @@ export interface Preferences { readonly androidNavigationStyle?: AndroidNavigationStyle; readonly fontSelections?: MobileFontSelections; readonly replyFontSize?: number | null; + readonly replyEmphasisWeights?: ReplyEmphasisWeights; readonly composerFontSize?: number | null; readonly baseFontSize?: number; readonly terminalFontSize?: number | null; @@ -194,6 +200,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { androidNavigationStyle?: AndroidNavigationStyle; fontSelections?: MobileFontSelections; replyFontSize?: number | null; + replyEmphasisWeights?: ReplyEmphasisWeights; composerFontSize?: number | null; baseFontSize?: number; terminalFontSize?: number | null; @@ -281,6 +288,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { } if (parsed.fontSelections !== undefined) preferences.fontSelections = normalizeFontSelections(parsed.fontSelections); + if (parsed.replyEmphasisWeights !== undefined) + preferences.replyEmphasisWeights = normalizeReplyEmphasisWeights(parsed.replyEmphasisWeights); if (typeof parsed.replyFontSize === "number" || parsed.replyFontSize === null) preferences.replyFontSize = parsed.replyFontSize; if (typeof parsed.composerFontSize === "number" || parsed.composerFontSize === null) diff --git a/docs/user/appearance.md b/docs/user/appearance.md index a52833d077628..e9fec28cfdcfc 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -18,11 +18,15 @@ Under mobile **Appearance → Fonts**, choose fonts independently for the interf composer, and code in messages or Markdown previews. Reply and composer sizes can follow Text size or use their own size. Diffs and the terminal keep their existing fonts. -To use your own font, choose **Import fonts from Files** and select static `.ttf` or `.otf` files. -Import matching regular, bold, and italic faces together; they are grouped by family name. Variable -font files and font collections are not supported by this import. Font files and selections stay on -this device and are not uploaded to connected environments. Removing an imported family resets any -choices using it to Default. +On iOS, choose **Choose installed font…** from a font menu to open the system font picker. +iOS manages each family's matching styles. Installed fonts must be available to apps through iOS; +if a selected font becomes unavailable, the app uses its default. Android offers the built-in +system sans, serif, and monospace choices. + +Use **Reply emphasis weight** to choose an available Medium, Semibold, or Bold face for emphasized +reply text. Default keeps the font's existing emphasis. The choice is remembered for each reply +font, so changing fonts restores that font's choice. Unavailable weights are disabled. Reset font +choices and message sizes to clear these selections. Fonts and preferences stay on this device. On Android 12 or newer, choose the **Material You** theme in Appearance to use colors from your wallpaper. Selecting another theme replaces those colors. Like other themes, Material You From b520494246151a54f66a9c32c199d26de2e55016 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 20 Oct 2026 00:42:48 -0400 Subject: [PATCH 06/11] perf(mobile): retain unchanged fonts on foreground refresh --- .../AppearancePreferencesProvider.tsx | 17 +++-- apps/mobile/src/lib/mobileFonts.test.ts | 60 ++++++++++++++++ apps/mobile/src/lib/mobileFonts.ts | 68 +++++++++++++------ 3 files changed, 119 insertions(+), 26 deletions(-) diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index e2f5a29862e44..f6221b3e92d4e 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -21,6 +21,7 @@ import { appAtomRegistry } from "../../../state/atom-registry"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; import { normalizeFontSelections, + reconcileInstalledFonts, normalizeReplyEmphasisWeights, resolveReplyEmphasis, type ReplyEmphasisWeight, @@ -269,6 +270,14 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN useEffect(() => { let cancelled = false; let generation = 0; + const updateUnavailableFonts = (missing: readonly string[]) => { + setUnavailableFonts((previous) => + previous.length === missing.length && + previous.every((name, index) => name === missing[index]) + ? previous + : missing, + ); + }; const refresh = () => { generation = generation + 1; const request = generation; @@ -276,13 +285,13 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN .then((resolved) => { if (cancelled || request !== generation) return; const available = new Map(resolved.map((font) => [font.name, font])); - setInstalledFonts(available); - setUnavailableFonts(selectedFontNames.filter((name) => !available.has(name))); + setInstalledFonts((previous) => reconcileInstalledFonts(previous, available)); + updateUnavailableFonts(selectedFontNames.filter((name) => !available.has(name))); }) .catch(() => { if (cancelled || request !== generation) return; - setInstalledFonts(new Map()); - setUnavailableFonts(selectedFontNames); + setInstalledFonts((previous) => (previous.size ? new Map() : previous)); + updateUnavailableFonts(selectedFontNames); }); }; refresh(); diff --git a/apps/mobile/src/lib/mobileFonts.test.ts b/apps/mobile/src/lib/mobileFonts.test.ts index de41baf0469fe..c29c3f3fef1d7 100644 --- a/apps/mobile/src/lib/mobileFonts.test.ts +++ b/apps/mobile/src/lib/mobileFonts.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_FONT_SELECTIONS, normalizeFontSelections, + reconcileInstalledFonts, normalizeReplyEmphasisWeights, resolveReplyEmphasis, resolveMobileFonts, @@ -118,3 +119,62 @@ describe("reply emphasis", () => { expect(resolveReplyEmphasis(serif, saved["SecondSerif-Regular"])).toBe("ExampleSerif-Semibold"); }); }); + +describe("font refresh identity", () => { + it("keeps the map and font references when native descriptors are unchanged or reordered", () => { + const second = { ...serif, name: "Other-Regular", regular: "Other-Regular" }; + const current = new Map([ + [serif.name, serif], + [second.name, second], + ]); + const refreshed = new Map([ + [second.name, { ...second }], + [serif.name, { ...serif, emphasisFaces: { ...serif.emphasisFaces } }], + ]); + expect(reconcileInstalledFonts(current, refreshed)).toBe(current); + }); + + it("applies changed faces and removals while keeping unrelated font references", () => { + const second = { ...serif, name: "Other-Regular", regular: "Other-Regular" }; + const current = new Map([ + [serif.name, serif], + [second.name, second], + ]); + const changed = { + ...serif, + emphasisFaces: { ...serif.emphasisFaces, "600": "Replacement-Semibold" }, + }; + const next = reconcileInstalledFonts( + current, + new Map([ + [serif.name, changed], + [second.name, { ...second }], + ]), + ); + expect(next).not.toBe(current); + expect(next.get(serif.name)?.emphasisFaces?.["600"]).toBe("Replacement-Semibold"); + expect(next.get(second.name)).toBe(second); + expect(current.get(serif.name)?.emphasisFaces?.["600"]).toBe("ExampleSerif-Semibold"); + const removed = reconcileInstalledFonts(next, new Map([[second.name, second]])); + expect(removed.has(serif.name)).toBe(false); + const empty = reconcileInstalledFonts(removed, new Map()); + expect(empty.size).toBe(0); + expect(reconcileInstalledFonts(empty, new Map())).toBe(empty); + }); + + it.each(["ios", "android"])( + "reuses built-in family objects across unrelated %s font refreshes", + (platform) => { + const selections = { + interface: "system", + reply: "serif", + composer: "monospace", + code: "monospace", + }; + const before = resolveMobileFonts(selections, new Map(), platform); + const after = resolveMobileFonts(selections, new Map([[serif.name, serif]]), platform); + for (const role of ["interface", "reply", "composer", "code"] as const) + expect(after[role]).toBe(before[role]); + }, + ); +}); diff --git a/apps/mobile/src/lib/mobileFonts.ts b/apps/mobile/src/lib/mobileFonts.ts index 6a3f10fa1abb2..0d15d6a4475f7 100644 --- a/apps/mobile/src/lib/mobileFonts.ts +++ b/apps/mobile/src/lib/mobileFonts.ts @@ -26,6 +26,51 @@ export const BUILT_IN_FONT_OPTIONS = [ { id: "monospace", label: "Monospace" }, ] as const; +const BUILT_IN_FAMILIES = { + ios: { + system: { regular: "System", medium: "System", bold: "System" }, + serif: { + regular: "Georgia", + medium: "Georgia", + bold: "Georgia-Bold", + emphasisFaces: { "700": "Georgia-Bold" }, + }, + mono: { + regular: "Menlo-Regular", + medium: "Menlo-Regular", + bold: "Menlo-Bold", + emphasisFaces: { "700": "Menlo-Bold" }, + }, + }, + android: { + system: { regular: "sans-serif", medium: "sans-serif-medium", bold: "sans-serif" }, + serif: { regular: "serif", medium: "serif", bold: "serif" }, + mono: { regular: "monospace", medium: "monospace", bold: "monospace" }, + }, +} as const; + +export function reconcileInstalledFonts( + previous: ReadonlyMap, + available: ReadonlyMap, +): ReadonlyMap { + let changed = previous.size !== available.size; + const next = new Map(); + for (const [name, font] of available) { + const prior = previous.get(name); + const same = + prior && + (["name", "label", "regular", "medium", "bold"] as const).every( + (key) => prior[key] === font[key], + ) && + REPLY_EMPHASIS_OPTIONS.every( + ({ id }) => prior.emphasisFaces?.[id] === font.emphasisFaces?.[id], + ); + if (!same) changed = true; + next.set(name, same ? prior : font); + } + return changed ? next : previous; +} + export function normalizeFontSelections(value: unknown): MobileFontSelections { const record = typeof value === "object" && value !== null ? (value as Record) : {}; @@ -51,28 +96,7 @@ export function resolveMobileFonts( platform: string, ): Readonly> { const defaults = FONT_FAMILIES; - const system = - platform === "ios" - ? { regular: "System", medium: "System", bold: "System" } - : { regular: "sans-serif", medium: "sans-serif-medium", bold: "sans-serif" }; - const serif = - platform === "ios" - ? { - regular: "Georgia", - medium: "Georgia", - bold: "Georgia-Bold", - emphasisFaces: { "700": "Georgia-Bold" }, - } - : { regular: "serif", medium: "serif", bold: "serif" }; - const mono = - platform === "ios" - ? { - regular: "Menlo-Regular", - medium: "Menlo-Regular", - bold: "Menlo-Bold", - emphasisFaces: { "700": "Menlo-Bold" }, - } - : { regular: "monospace", medium: "monospace", bold: "monospace" }; + const { system, serif, mono } = BUILT_IN_FAMILIES[platform === "ios" ? "ios" : "android"]; const resolve = (selection: string, fallback: MobileFontFamily): MobileFontFamily => { if (selection === "system") return system; if (selection === "serif") return serif; From 17a4f20306ca9f44ce9ce31af3f7cebcb436844e Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 27 Oct 2026 15:11:36 -0400 Subject: [PATCH 07/11] fix: preserve feature behavior after upstream rebase --- .../appearance/AppearancePreferencesProvider.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index f6221b3e92d4e..0728f4a961868 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -1,4 +1,13 @@ -import { createContext, startTransition, use, useMemo, type ReactNode } from "react"; +import { + createContext, + startTransition, + use, + useCallback, + useMemo, + useState, + useEffect, + type ReactNode, +} from "react"; import { AppState, Appearance, Platform, useColorScheme } from "react-native"; import { manufacturer } from "expo-device"; From 4ca31446bc374149eeda3fbf88147333af7d8838 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 28 Oct 2026 09:00:28 -0400 Subject: [PATCH 08/11] fix: preserve feature behavior on current upstream --- .../settings/appearance/AppearancePreferencesProvider.tsx | 1 + .../settings/appearance/sections/FontAppearanceSection.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index 0728f4a961868..74d650181f46b 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -276,6 +276,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN new Map(), ); const [unavailableFonts, setUnavailableFonts] = useState([]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reloads native installed font faces when selections change or the app returns to the foreground useEffect(() => { let cancelled = false; let generation = 0; diff --git a/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx index 1eb18bf212f4f..d98e70423dae1 100644 --- a/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx @@ -5,7 +5,7 @@ import { AppText as Text } from "../../../../components/ui/AppText"; import { ListRowLayout } from "../../../../components/ui/ListRowLayout"; import { Menu } from "../../../../components/ui/Menu"; import { SettingsSection } from "../../components/SettingsSection"; -import { SettingsActionRow } from "../../components/SettingsActionRow"; +import { ListActionRow as SettingsActionRow } from "../../../../components/ui/ListActionRow"; import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; import { FontSizeSliderRow } from "../components/FontSizeSliderRow"; import { useAppearancePreferences } from "../AppearancePreferencesProvider"; From c5e4c7ce19cd97ccc8c3e8795a980c2d2de20e7f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 22:28:27 +0000 Subject: [PATCH 09/11] fix(mobile): preserve bold headings and update preview test fonts --- apps/mobile/src/lib/filePreview.test.ts | 9 ++++++++- apps/mobile/src/native/EnrichedMarkdownText.tsx | 2 -- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts index 47f00f0e2f1fb..01ad52ffd7d3c 100644 --- a/apps/mobile/src/lib/filePreview.test.ts +++ b/apps/mobile/src/lib/filePreview.test.ts @@ -19,6 +19,7 @@ import { acquireAudioSession, activateAudioSession, releaseAudioSession } from " import { ThreadFeed } from "../features/threads/ThreadFeed"; import type { ThreadFeedEntry } from "./threadActivity"; import type { MarkdownImageRenderer } from "../native/SelectableMarkdownText"; +import { DEFAULT_FONT_SELECTIONS, resolveMobileFonts } from "./mobileFonts"; const audio = vi.hoisted(() => ({ status: { @@ -268,7 +269,13 @@ vi.mock("../features/layout/workspace-content-width", () => ({ useWorkspaceSettledContentWidth: () => null, })); vi.mock("../features/settings/appearance/AppearancePreferencesProvider", () => ({ - useAppearancePreferences: () => ({ appearance: { baseFontSize: 14 }, themeAppearance: "dark" }), + useAppearancePreferences: () => ({ + appearance: { baseFontSize: 14 }, + themeAppearance: "dark", + fonts: resolveMobileFonts(DEFAULT_FONT_SELECTIONS, new Map(), "ios"), + replyFontSize: 14, + replyEmphasisFontFamily: "DMSans-Bold", + }), })); vi.mock("./useUniwindTheme", () => ({ useUniwindTheme: () => new Proxy({}, { get: () => "#000000" }), diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index 878daebe6b911..9e2c1c926c621 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -68,8 +68,6 @@ function enrichedStyle( ...body, color: style.strongColor, fontFamily: style.headingFontFamily, - fontWeight: - style.headingFontFamily === style.fontFamily ? ("bold" as const) : ("normal" as const), fontSize, lineHeight: fontSize * 1.35, marginTop: 8, From 9ff06dacf6ae11e0a95eec14b9f37bde9ca54888 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 12 Nov 2026 10:01:16 -0500 Subject: [PATCH 10/11] test(mobile): isolate composer appearance preferences --- apps/mobile/src/components/ComposerEditor.ios.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/mobile/src/components/ComposerEditor.ios.test.tsx b/apps/mobile/src/components/ComposerEditor.ios.test.tsx index f7ce1f9918d85..339e1f6bf0397 100644 --- a/apps/mobile/src/components/ComposerEditor.ios.test.tsx +++ b/apps/mobile/src/components/ComposerEditor.ios.test.tsx @@ -130,6 +130,12 @@ vi.mock("../lib/contextChipIconSource", () => ({ contextChipIconUri: () => "" }) vi.mock("../features/settings/appearance/useScaledTextRole", () => ({ useScaledTextRole: () => ({ fontSize: 16, lineHeight: 22 }), })); +vi.mock("../features/settings/appearance/AppearancePreferencesProvider", () => ({ + useAppearancePreferences: () => ({ + fonts: { composer: { regular: "System" } }, + composerFontSize: 16, + }), +})); vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "0000" })); vi.mock("../state/assets", () => ({ assetEnvironment: {} })); vi.mock("../state/attachments", () => ({ attachmentEnvironment: {} })); From 27d19a53579d253737cbb41792256b99debf4d9f Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 12 Nov 2026 10:29:35 -0500 Subject: [PATCH 11/11] fix(mobile): avoid repeated missing font prompts --- .../sections/FontAppearanceSection.tsx | 9 ++- .../mobile/src/native/installed-fonts.test.ts | 61 +++++++++++++++++++ apps/mobile/src/native/installed-fonts.ts | 34 ++++++++++- 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/native/installed-fonts.test.ts diff --git a/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx index d98e70423dae1..f8b42bf860ec0 100644 --- a/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx @@ -81,7 +81,14 @@ export function FontAppearanceSection() { }} > A reply in your selected font.{" "} - Emphasized text. + + Emphasized text. + Defaults follow the interface. These choices stay on this device. diff --git a/apps/mobile/src/native/installed-fonts.test.ts b/apps/mobile/src/native/installed-fonts.test.ts new file mode 100644 index 0000000000000..6970024aca5e6 --- /dev/null +++ b/apps/mobile/src/native/installed-fonts.test.ts @@ -0,0 +1,61 @@ +import { expect, it, vi } from "vite-plus/test"; +import type { InstalledMobileFont } from "../lib/mobileFonts"; + +const native = vi.hoisted(() => ({ + resolveInstalledFonts: vi.fn<(names: readonly string[]) => Promise>(), + pickInstalledFont: vi.fn<() => Promise>(), +})); + +vi.mock("expo", () => ({ requireOptionalNativeModule: () => native })); +vi.mock("react-native", () => ({ Platform: { OS: "ios" } })); + +async function setup() { + vi.resetModules(); + native.resolveInstalledFonts.mockReset(); + native.pickInstalledFont.mockReset(); + return import("./installed-fonts"); +} + +it("does not request a missing font again until the user selects it in the picker", async () => { + const fonts = await setup(); + const font = { + name: "Removed-Regular", + label: "Removed", + regular: "Removed-Regular", + medium: "Removed-Regular", + bold: "Removed-Regular", + }; + native.resolveInstalledFonts.mockResolvedValue([]); + expect(await fonts.resolveInstalledFonts([font.name])).toEqual([]); + expect(await fonts.resolveInstalledFonts([font.name])).toEqual([]); + expect(native.resolveInstalledFonts).toHaveBeenCalledTimes(1); + + native.pickInstalledFont.mockResolvedValue(null); + await fonts.pickInstalledFont(font.name); + expect(await fonts.resolveInstalledFonts([font.name])).toEqual([]); + expect(native.resolveInstalledFonts).toHaveBeenCalledTimes(1); + + native.pickInstalledFont.mockResolvedValue(font); + await fonts.pickInstalledFont(font.name); + native.resolveInstalledFonts.mockResolvedValue([font]); + expect(await fonts.resolveInstalledFonts([font.name])).toEqual([font]); + native.resolveInstalledFonts.mockResolvedValue([]); + expect(await fonts.resolveInstalledFonts([font.name])).toEqual([]); + expect(await fonts.resolveInstalledFonts([font.name])).toEqual([]); + expect(native.resolveInstalledFonts).toHaveBeenCalledTimes(3); +}); + +it("shares an in-flight request and permits another attempt after a native failure", async () => { + const fonts = await setup(); + const pending = Promise.withResolvers(); + native.resolveInstalledFonts.mockReturnValue(pending.promise); + const first = fonts.resolveInstalledFonts(["Removed-Regular"]); + const second = fonts.resolveInstalledFonts(["Removed-Regular"]); + const settled = Promise.allSettled([first, second]); + pending.reject(new Error("Native request failed")); + expect((await settled).map((result) => result.status)).toEqual(["rejected", "rejected"]); + expect(native.resolveInstalledFonts).toHaveBeenCalledTimes(1); + native.resolveInstalledFonts.mockResolvedValue([]); + expect(await fonts.resolveInstalledFonts(["Removed-Regular"])).toEqual([]); + expect(native.resolveInstalledFonts).toHaveBeenCalledTimes(2); +}); diff --git a/apps/mobile/src/native/installed-fonts.ts b/apps/mobile/src/native/installed-fonts.ts index 6af819090fa0f..41b9a6579722c 100644 --- a/apps/mobile/src/native/installed-fonts.ts +++ b/apps/mobile/src/native/installed-fonts.ts @@ -10,13 +10,43 @@ const nativeControls = requireOptionalNativeModule<{ export const canPickInstalledFont = Platform.OS === "ios" && typeof nativeControls?.pickInstalledFont === "function"; +// CoreText can show a system dialog for missing fonts, including on foreground refresh. +const missingFontNames = new Set(); +const pendingResolutions = new Map>(); + export async function pickInstalledFont(selectedName: string | null) { if (!canPickInstalledFont || !nativeControls?.pickInstalledFont) throw new Error("Installed font selection requires an updated iOS app."); - return nativeControls.pickInstalledFont(selectedName); + const font = await nativeControls.pickInstalledFont(selectedName); + if (font) { + missingFontNames.delete(font.name); + pendingResolutions.delete(font.name); + } + return font; } export async function resolveInstalledFonts(names: readonly string[]) { if (!names.length || !nativeControls?.resolveInstalledFonts) return []; - return nativeControls.resolveInstalledFonts(names); + const requestedNames = [...new Set(names)].filter( + (name) => !missingFontNames.has(name) && !pendingResolutions.has(name), + ); + if (requestedNames.length) { + const request = nativeControls.resolveInstalledFonts(requestedNames); + for (const name of requestedNames) { + const resolution = request + .then((fonts) => { + const font = fonts.find((candidate) => candidate.name === name); + if (!font && pendingResolutions.get(name) === resolution) missingFontNames.add(name); + return font; + }) + .finally(() => { + if (pendingResolutions.get(name) === resolution) pendingResolutions.delete(name); + }); + pendingResolutions.set(name, resolution); + } + } + const resolved = await Promise.all( + [...new Set(names)].map((name) => pendingResolutions.get(name)), + ); + return resolved.filter((font) => font !== undefined); }