From a3d3d3cd5584f84bbd4cb099f7f15d42761803bf Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 19 Oct 2026 18:41:41 -0400 Subject: [PATCH 01/10] 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 730b9e1a97f34..0b18ff9c0217a 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -42,10 +42,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 f9f81459bbf08..7891887e05ffc 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 8045fb3dd3d4e..4f76a801235c8 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"; @@ -26,6 +27,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 644c6d25ee3d4..7369a917533db 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 024fc14179fd2..3daa5dfefdcea 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -402,11 +402,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); @@ -1102,6 +1107,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 da127a29ef9bf..f30b2b5114bb2 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -140,7 +140,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; -import { FONT_FAMILIES } from "../../lib/fontFamilies"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; @@ -942,8 +941,15 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(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"]; @@ -963,55 +969,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. */ @@ -2186,11 +2217,12 @@ export const ThreadFeed = memo(function ThreadFeed({ const userScrollSettleTimerRef = 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 [measuredViewportWidth, setViewportWidth] = useState(() => props.layoutVariant === "split" ? 0 : windowWidth, @@ -3111,7 +3143,7 @@ export const ThreadFeed = memo(function ThreadFeed({ // 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 and starts a reflow 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 13739f0f24a1a..1936ae8b08bc3 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -37,7 +37,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, @@ -60,6 +60,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, @@ -82,7 +84,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 ddb40d22db37e..28315b9ca99e5 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 c649b12292be6..22002c3d8d0a5 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"; @@ -25,8 +26,6 @@ import { import { contextChipIconUri } 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, @@ -159,9 +158,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; useImperativeHandle( ref, @@ -297,12 +296,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 e1ed9f7425668..0eae8dac3131a 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -24,9 +24,7 @@ import { contextRecordFileName, } from "../lib/composerChipPresentation"; import { contextChipIconUri } 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"; @@ -279,13 +277,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( @@ -56,6 +57,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 43ac75e185411..a88b2c676a00c 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -23,6 +23,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"; @@ -97,6 +104,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; @@ -179,6 +190,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; @@ -262,6 +277,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 49d8e02d79a82..803d28be1aa3d 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 a69c654b8e029dad68b355bd03a01f3c617eee14 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 19 Oct 2026 20:03:48 -0400 Subject: [PATCH 02/10] fix(mobile): resolve imported composer fonts and isolate import failures --- .../t3composereditor/T3ComposerEditorView.kt | 11 +-- .../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, 150 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 ecc37df17c993..1f03e3cb5dd98 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 @@ -26,6 +26,7 @@ import android.view.inputmethod.InputConnection import android.view.inputmethod.InputConnectionWrapper import android.view.inputmethod.InputMethodManager import android.widget.EditText +import com.facebook.react.common.assets.ReactFontManager import expo.modules.kotlin.AppContext import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView @@ -268,11 +269,11 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } fun setFontFamily(fontFamily: String) { - editor.typeface = if (fontFamily.contains("Mono", ignoreCase = true)) { - Typeface.MONOSPACE - } else { - Typeface.DEFAULT - } + editor.typeface = ReactFontManager.getInstance().getTypeface( + fontFamily, + Typeface.NORMAL, + context.assets, + ) 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 f54f75caf5f50..c9cca4ae7c759 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -1060,9 +1060,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 @@ -1074,8 +1086,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 d66e431302e582af52008a384254ba389ff1f38e Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 19 Oct 2026 23:33:02 -0400 Subject: [PATCH 03/10] 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 ea885f819984f..5c917790b6442 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -252,6 +252,7 @@ const config: ExpoConfig = { `webcredentials:${variant.relyingParty}`, ], 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 a5b8f7a44e3a6..29b9f784e6baa 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -8,6 +8,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") @@ -51,6 +52,25 @@ public final class T3NativeControlsModule: Module { ViewName("ScrollEdgeBleed") } + 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, @@ -84,9 +104,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 a88b2c676a00c..314bb9823f78b 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -23,12 +23,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"; @@ -105,7 +100,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; @@ -191,7 +185,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { themeMode?: MobileThemeMode; androidNavigationStyle?: AndroidNavigationStyle; fontSelections?: MobileFontSelections; - importedFonts?: readonly ImportedFontFace[]; replyFontSize?: number | null; composerFontSize?: number | null; baseFontSize?: number; @@ -279,8 +272,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 e2866f359924ab47d0a80bb5957c8e4e7f33b0e5 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 19 Oct 2026 23:37:19 -0400 Subject: [PATCH 04/10] 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 29b9f784e6baa..5df7bc0ffa04b 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -53,22 +53,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 + 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) } - 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) } + MainActor.assumeIsolated { + T3InstalledFonts.resolve(names) { promise.resolve($0) } + } }.runOnQueue(.main) AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in From 7d8a2fb9f02b44e66dcde38d0883014290bc685c Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 20 Oct 2026 00:33:34 -0400 Subject: [PATCH 05/10] 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 7891887e05ffc..4a6466ffeb699 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 f30b2b5114bb2..11b248ef7819c 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -941,7 +941,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { }); function useMarkdownStyles(): MarkdownStyleSets { - const { appearance, fonts, replyFontSize } = useAppearancePreferences(); + const { appearance, fonts, replyFontSize, replyEmphasisFontFamily } = useAppearancePreferences(); const replyTypography = useMemo( () => resolveNativeMarkdownTypography(replyFontSize), [replyFontSize], @@ -1017,12 +1017,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, @@ -2217,12 +2218,12 @@ export const ThreadFeed = memo(function ThreadFeed({ const userScrollSettleTimerRef = 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 [measuredViewportWidth, setViewportWidth] = useState(() => props.layoutVariant === "split" ? 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 314bb9823f78b..05b93d5187eec 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -23,7 +23,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"; @@ -101,6 +106,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; @@ -186,6 +192,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { androidNavigationStyle?: AndroidNavigationStyle; fontSelections?: MobileFontSelections; replyFontSize?: number | null; + replyEmphasisWeights?: ReplyEmphasisWeights; composerFontSize?: number | null; baseFontSize?: number; terminalFontSize?: number | null; @@ -272,6 +279,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 803d28be1aa3d..97aca2f1747ea 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 acd0f7ea3cdef83e42cc7080a59b49e7d597d5a6 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 20 Oct 2026 00:42:48 -0400 Subject: [PATCH 06/10] 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 df8691e4cbb4841a14ca62352d8b8e56f14c5c55 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 26 Oct 2026 09:46:02 -0400 Subject: [PATCH 07/10] fix(mobile): recompute composer line height when its font changes --- .../java/expo/modules/t3composereditor/T3ComposerEditorView.kt | 1 + 1 file changed, 1 insertion(+) 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 1f03e3cb5dd98..e279902db3289 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 @@ -274,6 +274,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( Typeface.NORMAL, context.assets, ) + applyLineHeight() editor.applyPlaceholder() } From 05836a8eab8cc38bbff17c1661c3ce158ece2634 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 27 Oct 2026 15:11:36 -0400 Subject: [PATCH 08/10] 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 2f3717f34fc223a4b7a20e0ffec77d6a71d99d4d Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 28 Oct 2026 09:00:28 -0400 Subject: [PATCH 09/10] 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 0532566e0e6aec2b401be2222f4f3c6bb241ff9e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 22:28:27 +0000 Subject: [PATCH 10/10] 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 97a5051577026..31203be8858b3 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: { @@ -257,7 +258,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 1936ae8b08bc3..762ec63e61163 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -60,8 +60,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,