diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index e6d388b9a9856..1390004c57596 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -254,6 +254,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/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/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 efa54e2ec374a..1065e78f0acad 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 @@ -28,6 +28,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 @@ -270,11 +271,12 @@ 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, + ) + applyLineHeight() editor.applyPlaceholder() } diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 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/modules/t3-native-controls/ios/T3InstalledFonts.swift b/apps/mobile/modules/t3-native-controls/ios/T3InstalledFonts.swift new file mode 100644 index 0000000000000..e1a47ba4e7f3d --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3InstalledFonts.swift @@ -0,0 +1,100 @@ +import CoreText +import UIKit + +@MainActor +enum T3InstalledFonts { + static func describe(_ font: UIFont, name: String? = nil) -> [String: Any] { + 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 + } + // 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: 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. + 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: Any]?) -> Void + private var picker: UIFontPickerViewController? + private var finished = false + + init(completion: @escaping ([String: Any]?) -> 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: Any]?) { + 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..5df7bc0ffa04b 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,29 @@ public final class T3NativeControlsModule: Module { ViewName("ScrollEdgeBleed") } + AsyncFunction("pickInstalledFont") { (selectedName: String?, promise: Promise) in + 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) + } + }.runOnQueue(.main) + + AsyncFunction("resolveInstalledFonts") { (names: [String], promise: Promise) in + MainActor.assumeIsolated { + 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 +108,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/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index f9f81459bbf08..4a6466ffeb699 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, replyEmphasisFontFamily } = 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,9 +67,10 @@ export function useMarkdownPreviewStyles(): MarkdownPreviewStyles { fontSize: nativeMarkdownTypography.fontSize, lineHeight: nativeMarkdownTypography.lineHeight, headingFontSizes: nativeMarkdownTypography.headingFontSizes, + codeFontFamily: fonts.code.regular, fontFamily: regularFontFamily, headingFontFamily: boldFontFamily, - boldFontFamily, + boldFontFamily: replyEmphasisFontFamily, }, }; } 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..74d650181f46b 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"; @@ -19,6 +28,19 @@ import { } from "../../../lib/appearancePreferences"; import { appAtomRegistry } from "../../../state/atom-registry"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; +import { + normalizeFontSelections, + reconcileInstalledFonts, + normalizeReplyEmphasisWeights, + resolveReplyEmphasis, + type ReplyEmphasisWeight, + resolveMobileFonts, + type MobileFontRole, + type MobileFontSelections, + type InstalledMobileFont, +} from "../../../lib/mobileFonts"; +import { resolveInstalledFonts } from "../../../native/installed-fonts"; +import { normalizeBaseFontSize } from "../../../lib/appearancePreferences"; import type { Preferences } from "../../../persistence/mobile-preferences"; import { isSystemColorsAvailable, @@ -45,6 +67,20 @@ import { } from "../../../lib/mobileThemeRuntime"; interface AppearancePreferencesContextValue { + readonly fonts: ReturnType; + readonly fontSelections: MobileFontSelections; + 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; + 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 +258,84 @@ 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 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 [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; + 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; + void resolveInstalledFonts(selectedFontNames) + .then((resolved) => { + if (cancelled || request !== generation) return; + const available = new Map(resolved.map((font) => [font.name, font])); + setInstalledFonts((previous) => reconcileInstalledFonts(previous, available)); + updateUnavailableFonts(selectedFontNames.filter((name) => !available.has(name))); + }) + .catch(() => { + if (cancelled || request !== generation) return; + setInstalledFonts((previous) => (previous.size ? new Map() : previous)); + updateUnavailableFonts(selectedFontNames); + }); + }; + refresh(); + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") refresh(); + }); + return () => { + cancelled = true; + subscription.remove(); + }; + }, [selectedFontNames]); + const fonts = useMemo( + () => 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, + "--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 +404,61 @@ 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 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) }), + [updatePreferences], + ); + const setComposerFontSize = useCallback( + (size: number | null) => + updatePreferences({ composerFontSize: size === null ? null : normalizeBaseFontSize(size) }), + [updatePreferences], + ); const value = useMemo( (): AppearancePreferencesContextValue => ({ appearance, + fonts, + fontSelections, + installedFonts, + unavailableFonts, + replyFontSize, + replyEmphasisWeight, + replyEmphasisFontFamily, + setReplyEmphasisWeight, + composerFontSize, + customReplyFontSize, + customComposerFontSize, + setFontSelection, + setReplyFontSize, + setComposerFontSize, themeId, themeIds, themeMode, @@ -320,6 +483,20 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN }), [ appearance, + fonts, + fontSelections, + installedFonts, + unavailableFonts, + replyFontSize, + replyEmphasisWeight, + replyEmphasisFontFamily, + setReplyEmphasisWeight, + composerFontSize, + customReplyFontSize, + customComposerFontSize, + setFontSelection, + setReplyFontSize, + setComposerFontSize, themeId, themeIds, themeMode, @@ -346,7 +523,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN return ( - {props.children} + {props.children} ); diff --git a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx index afcf4ba30d9a1..96d396e7c5b57 100644 --- a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx +++ b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx @@ -20,12 +20,17 @@ export function AppearancePreviewSeparator() { export function TextAppearancePreview(props: { readonly fontSize: number }) { const sizes = resolveMarkdownFontSizes(props.fontSize); + const { fonts } = useAppearancePreferences(); return ( The quick brown fox jumps over the lazy dog. diff --git a/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx new file mode 100644 index 0000000000000..d98e70423dae1 --- /dev/null +++ b/apps/mobile/src/features/settings/appearance/sections/FontAppearanceSection.tsx @@ -0,0 +1,229 @@ +import { useState } from "react"; +import { Alert, View } from "react-native"; +import { useAtomSet } from "@effect/atom-react"; +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 { ListActionRow as SettingsActionRow } from "../../../../components/ui/ListActionRow"; +import { SettingsSwitchRow } from "../../components/SettingsSwitchRow"; +import { FontSizeSliderRow } from "../components/FontSizeSliderRow"; +import { useAppearancePreferences } from "../AppearancePreferencesProvider"; +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"; +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, + installedFonts, + unavailableFonts, + fonts, + replyFontSize, + replyEmphasisWeight, + replyEmphasisFontFamily, + setReplyEmphasisWeight, + composerFontSize, + customReplyFontSize, + customComposerFontSize, + setFontSelection, + setReplyFontSize, + setComposerFontSize, + } = useAppearancePreferences(); + const savePreferences = useAtomSet(updateMobilePreferencesAtom, { mode: "promise" }); + const [busy, setBusy] = useState(false); + const options = [ + ...BUILT_IN_FONT_OPTIONS, + ...[...installedFonts.values()].map((font) => ({ + id: `installed:${font.name}`, + label: font.label, + })), + ]; + const report = (error: unknown) => + Alert.alert( + "Custom fonts", + error instanceof Error ? error.message : "Could not update fonts. Please try again.", + ); + + const chooseInstalledFont = (role: MobileFontRole) => { + setBusy(true); + 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 ( + + + + A reply in your selected font.{" "} + Emphasized text. + + + Defaults follow the interface. These choices stay on this device. + + + {ROLES.map(({ role, label }) => ( + ({ + 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 }) => + nativeEvent.event === "choose-installed" + ? chooseInstalledFont(role) + : setFontSelection(role, nativeEvent.event === "default" ? "" : nativeEvent.event) + } + > + + option.id === fontSelections[role])?.label ?? "Unavailable" + } + /> + + + ))} + ({ + 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. + + + setReplyFontSize(enabled ? replyFontSize : null)} + /> + {customReplyFontSize ? ( + + ) : null} + setComposerFontSize(enabled ? composerFontSize : null)} + /> + {customComposerFontSize ? ( + + ) : null} + {unavailableFonts.length ? ( + + + Unavailable fonts: {unavailableFonts.join(", ")}. Using defaults. Choose an installed + font again or select Default. + + + ) : null} + + void savePreferences({ + fontSelections: DEFAULT_FONT_SELECTIONS, + replyEmphasisWeights: {}, + 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 52ef705e93b29..41e8e0a6f325b 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -410,11 +410,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); @@ -1047,6 +1052,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 fb4fd3fe1123e..03a3b05e96e2b 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -144,7 +144,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"; @@ -1298,8 +1297,15 @@ function MarkdownDetailsFold(props: { } function useMarkdownStyles(): MarkdownStyleSets { - const { appearance } = useAppearancePreferences(); - const nativeMarkdownTypography = resolveNativeMarkdownTypography(appearance.baseFontSize); + const { appearance, fonts, replyFontSize, replyEmphasisFontFamily } = 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"]; @@ -1319,55 +1325,81 @@ 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: replyEmphasisFontFamily, + }, }, - }, - }; + }; + }, [ + fonts.reply, + replyEmphasisFontFamily, + 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. */ @@ -2623,11 +2655,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, 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}:${replyEmphasisFontFamily}:${fonts.code.regular}`; const settledPaneWidth = useWorkspaceSettledContentWidth(); const [measuredViewportWidth, setViewportWidth] = useState(() => props.layoutVariant === "split" ? 0 : windowWidth, @@ -3563,7 +3596,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/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/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 new file mode 100644 index 0000000000000..c29c3f3fef1d7 --- /dev/null +++ b/apps/mobile/src/lib/mobileFonts.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + DEFAULT_FONT_SELECTIONS, + normalizeFontSelections, + reconcileInstalledFonts, + normalizeReplyEmphasisWeights, + resolveReplyEmphasis, + resolveMobileFonts, +} from "./mobileFonts"; + +const serif = { + name: "ExampleSerif-Regular", + label: "Example Serif", + regular: "ExampleSerif-Regular", + medium: "ExampleSerif-Medium", + bold: "ExampleSerif-Bold", + emphasisFaces: { + "500": "ExampleSerif-Medium", + "600": "ExampleSerif-Semibold", + "700": "ExampleSerif-Bold", + }, +}; + +describe("installed mobile fonts", () => { + it("preserves installed font names and resets removed import choices and invalid preferences", () => { + expect( + normalizeFontSelections({ + interface: "serif", + reply: "installed:ExampleSerif-Regular", + composer: "imported:Old Family", + code: "installed:", + }), + ).toEqual({ + interface: "serif", + reply: "installed:ExampleSerif-Regular", + composer: "", + code: "", + }); + expect( + normalizeFontSelections({ reply: 42, interface: "installed:" + "x".repeat(251) }), + ).toEqual(DEFAULT_FONT_SELECTIONS); + }); + + it("uses the selected family's native weight faces independently for each role", () => { + const fonts = resolveMobileFonts( + { + ...DEFAULT_FONT_SELECTIONS, + interface: "serif", + reply: `installed:${serif.name}`, + code: "monospace", + }, + new Map([[serif.name, serif]]), + "ios", + ); + 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("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(fonts.interface.regular).toBe("sans-serif"); + expect(fonts.reply.regular).toBe("serif"); + expect(fonts.composer.regular).toBe("monospace"); + 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"); + }); +}); + +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 new file mode 100644 index 0000000000000..0d15d6a4475f7 --- /dev/null +++ b/apps/mobile/src/lib/mobileFonts.ts @@ -0,0 +1,138 @@ +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 MobileFontFamily { + readonly regular: string; + readonly medium: string; + readonly bold: string; + readonly emphasisFaces?: Readonly>>; +} +export interface InstalledMobileFont extends MobileFontFamily { + readonly name: string; + readonly label: 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; + +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) : {}; + 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("installed:") && candidate.slice(10).trim().length > 0)) + ? candidate + : "", + ]; + }), + ) as unknown as MobileFontSelections; +} + +export function resolveMobileFonts( + selections: MobileFontSelections, + installed: ReadonlyMap, + platform: string, +): Readonly> { + const defaults = FONT_FAMILIES; + 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; + if (selection === "monospace") return mono; + if (!selection.startsWith("installed:")) return fallback; + return installed.get(selection.slice(10)) ?? fallback; + }; + const interfaceFont = resolve(selections.interface, defaults); + return { + interface: interfaceFont, + reply: resolve(selections.reply, interfaceFont), + composer: resolve(selections.composer, interfaceFont), + 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/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 6fa8c229a9539..1916de8389b26 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, @@ -82,7 +82,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 686007015268b..1ae7afbb3e421 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/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 43ac75e185411..05b93d5187eec 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, + normalizeReplyEmphasisWeights, + type MobileFontSelections, + type ReplyEmphasisWeights, +} 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 replyFontSize?: number | null; + readonly replyEmphasisWeights?: ReplyEmphasisWeights; + 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; + replyFontSize?: number | null; + replyEmphasisWeights?: ReplyEmphasisWeights; + 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.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) + 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..97aca2f1747ea 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -14,6 +14,20 @@ 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. + +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 can be selected separately for light and dark appearances.