import type { ModelOAuthBegin, ThinkingLevel } from "@rakazo/contracts"; import { DEFAULT_MODEL_CONTEXT_WINDOW, DEFAULT_MODEL_MAX_TOKENS, MAX_MODEL_CONTEXT_WINDOW, MAX_MODEL_MAX_TOKENS, OPENAI_COMPATIBLE_BASE_URL_HINT, OPENAI_COMPATIBLE_PROVIDER_ID, openAiCompatibleConnectReady, parseModelContextWindow, parseModelMaxImagesPerPrompt, parseModelMaxTokens, } from "@rakazo/contracts"; import { createModelProbe, featuredModelProviders, initialModelProbeState } from "@rakazo/core"; import { useFocusEffect } from "expo-router"; import { useCallback, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Linking, Pressable, ScrollView, StyleSheet, Switch, Text, TextInput, View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { type MobileMe, type MobileModel, type MobileModelCredential, rpc } from "../lib/api"; import { mobileTokens } from "../lib/appearance"; import { useI18n } from "../lib/i18n"; import { presentMessageActionSheet } from "../lib/message-action-sheet"; import { cancelModelOAuthAttempt, finishModelOAuthAttempt, waitForModelOAuth, } from "../lib/model-auth"; import { native, useResolvedAppearance, useThemedStyles } from "../lib/native"; const THINKING_LEVEL_OPTIONS: ThinkingLevel[] = [ "minimal", "low", "medium", "high", "xhigh", "max", ]; function thinkingLevelLabel(level: ThinkingLevel, t: (message: string) => string): string { if (level === "xhigh") return t("Extra high"); if (level === "low") return t("Low"); if (level === "medium") return t("Medium"); if (level === "high") return t("High"); if (level === "minimal") return t("Minimal"); if (level === "max") return t("Max"); return level; } type ModelSelection = { provider?: string; modelId?: string; }; export default function Models() { const styles = useThemedStyles(createModelsStyles); const { t } = useI18n(); const colorScheme = useResolvedAppearance(); const [catalog, setCatalog] = useState([]); const [credentials, setCredentials] = useState([]); const [me, setMe] = useState(null); const [provider, setProvider] = useState(""); const [showAllProviders, setShowAllProviders] = useState(false); const [modelId, setModelId] = useState(""); const [apiKey, setApiKey] = useState(""); const [baseUrl, setBaseUrl] = useState(""); const [reasoning, setReasoning] = useState(false); const [thinkingLevel, setThinkingLevel] = useState(null); const [maxTokens, setMaxTokens] = useState(String(DEFAULT_MODEL_MAX_TOKENS)); const [contextWindow, setContextWindow] = useState(String(DEFAULT_MODEL_CONTEXT_WINDOW)); const [supportsImages, setSupportsImages] = useState(false); const [maxImagesPerPrompt, setMaxImagesPerPrompt] = useState(""); const [showEndpointHelp, setShowEndpointHelp] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); const [showApiKey, setShowApiKey] = useState(false); const [{ models: probeModels, probing }, setProbe] = useState(initialModelProbeState); const [modelProbe] = useState(() => createModelProbe(setProbe)); const resetOpenAiCompatibleProbe = modelProbe.reset; const [oauth, setOauth] = useState(null); const [pasteCode, setPasteCode] = useState(""); const [loading, setLoading] = useState(true); const [pending, setPending] = useState<"connect" | "default" | null>(null); const [oauthPending, setOauthPending] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const oauthAbortRef = useRef(null); const oauthLoginIdRef = useRef(null); const oauthCodeSubmittingRef = useRef(false); const cancelOAuth = useCallback(() => { const loginId = oauthLoginIdRef.current; oauthLoginIdRef.current = null; cancelModelOAuthAttempt(oauthAbortRef, () => { setOauth(null); setOauthPending(false); }); if (loginId) void rpc("models/cancelOAuth", { loginId }).catch(() => undefined); }, []); const load = useCallback(async (preferred: ModelSelection = {}) => { setError(null); const [nextMe, nextCatalog, nextCredentials] = await Promise.all([ rpc("me"), rpc("models/list"), rpc("models/credentials"), ]); const nextProvider = (preferred.provider && nextCatalog.some((entry) => entry.provider === preferred.provider) ? preferred.provider : nextMe.defaultProvider) ?? nextCatalog[0]?.provider ?? ""; const nextCredential = nextCredentials.find((entry) => entry.provider === nextProvider); const nextModel = nextProvider === OPENAI_COMPATIBLE_PROVIDER_ID ? preferred.modelId?.trim() || nextCredential?.modelId || (nextMe.defaultProvider === OPENAI_COMPATIBLE_PROVIDER_ID ? nextMe.defaultModel : "") || "" : (nextCatalog.find( (entry) => entry.provider === nextProvider && entry.id === preferred.modelId, )?.id ?? nextCatalog.find( (entry) => entry.provider === nextProvider && entry.id === nextMe.defaultModel, )?.id ?? nextCatalog.find((entry) => entry.provider === nextProvider)?.id ?? ""); setMe(nextMe); setCatalog(nextCatalog); setCredentials(nextCredentials); resetOpenAiCompatibleProbe(); setProvider(nextProvider); setModelId(nextModel); if (nextProvider === OPENAI_COMPATIBLE_PROVIDER_ID) { setBaseUrl(nextCredential?.baseUrl ?? ""); setReasoning(nextCredential?.reasoning ?? false); setThinkingLevel(nextCredential?.thinkingLevel ?? null); setMaxTokens(String(nextCredential?.maxTokens ?? DEFAULT_MODEL_MAX_TOKENS)); setContextWindow(String(nextCredential?.contextWindow ?? DEFAULT_MODEL_CONTEXT_WINDOW)); setSupportsImages(nextCredential?.supportsImages ?? false); setMaxImagesPerPrompt(String(nextCredential?.maxImagesPerPrompt ?? "")); } }, []); useFocusEffect( useCallback(() => { void load() .catch((err: unknown) => setError(err instanceof Error ? err.message : t("Could not load model settings")), ) .finally(() => setLoading(false)); return () => { modelProbe.invalidate(); cancelOAuth(); }; }, [cancelOAuth, load]), ); const groups = useMemo(() => { const grouped = new Map(); for (const entry of catalog) { const entries = grouped.get(entry.provider) ?? []; entries.push(entry); grouped.set(entry.provider, entries); } return [...grouped].map(([id, entries]) => ({ id, name: entries[0]?.providerName ?? id, entries, })); }, [catalog]); const featuredProviders = useMemo( () => featuredModelProviders( groups.map((group) => group.entries[0]!), provider, ), [groups, provider], ); const visibleGroups = useMemo(() => { if (showAllProviders) return groups; const byId = new Map(groups.map((group) => [group.id, group])); return featuredProviders.map((entry) => byId.get(entry.provider)!); }, [groups, featuredProviders, showAllProviders]); const modelsForProvider = catalog.filter((entry) => entry.provider === provider); const selected = modelsForProvider.find((entry) => entry.id === modelId) ?? modelsForProvider[0]; const isOpenAiCompatible = provider === OPENAI_COMPATIBLE_PROVIDER_ID; const credential = credentials.find((entry) => entry.provider === provider); const currentEntry = catalog.find( (entry) => entry.provider === me?.defaultProvider && entry.id === me?.defaultModel, ); const isActive = me?.defaultProvider === selected?.provider && me?.defaultModel === (isOpenAiCompatible ? modelId.trim() : selected?.id); const acceptsKey = selected?.auth !== "oauth"; const subscriptionSignIn = selected?.signIn !== undefined; const busy = pending !== null || oauthPending; const effectiveBaseUrl = baseUrl.trim(); const openAiCompatibleReady = openAiCompatibleConnectReady({ baseUrl: effectiveBaseUrl, modelId, }); function updateBaseUrl(nextBaseUrl: string) { setBaseUrl(nextBaseUrl); resetOpenAiCompatibleProbe(); setError(null); setNotice(null); } function updateApiKey(nextApiKey: string) { setApiKey(nextApiKey); resetOpenAiCompatibleProbe(); } function chooseProvider(nextProvider: string) { cancelOAuth(); const nextCredential = credentials.find((entry) => entry.provider === nextProvider); setProvider(nextProvider); setReasoning(nextCredential?.reasoning ?? false); setThinkingLevel(nextCredential?.thinkingLevel ?? null); setMaxTokens(String(nextCredential?.maxTokens ?? DEFAULT_MODEL_MAX_TOKENS)); setContextWindow(String(nextCredential?.contextWindow ?? DEFAULT_MODEL_CONTEXT_WINDOW)); setSupportsImages(nextCredential?.supportsImages ?? false); setMaxImagesPerPrompt(String(nextCredential?.maxImagesPerPrompt ?? "")); setModelId( nextProvider === OPENAI_COMPATIBLE_PROVIDER_ID ? (nextCredential?.modelId ?? "") : (catalog.find((entry) => entry.provider === nextProvider)?.id ?? ""), ); setBaseUrl( nextProvider === OPENAI_COMPATIBLE_PROVIDER_ID ? (nextCredential?.baseUrl ?? "") : "", ); setApiKey(""); resetOpenAiCompatibleProbe(); setError(null); setNotice(null); } async function probeServerModels() { if (!baseUrl.trim()) return; setError(null); setNotice(null); await modelProbe.probe({ baseUrl, apiKey, request: (input) => rpc<{ models: string[] }>("models/probeOpenAiCompatible", input), onSuccess: (models) => { setModelId((current) => current.trim() || models[0] || ""); setNotice( models.length === 0 ? t("Server found. Enter a model name.") : models.length === 1 ? t("Found {count} model.", { count: 1 }) : t("Found {count} models.", { count: models.length }), ); }, onError: (err) => setError(err instanceof Error ? err.message : t("Could not reach this model server")), }); } async function setModelDefault() { if (!selected || !credential) return; const activeModelId = isOpenAiCompatible ? modelId.trim() : selected.id; if (isOpenAiCompatible && !activeModelId) return; setError(null); setNotice(null); setPending("default"); try { await rpc("models/setDefault", { provider: selected.provider, modelId: activeModelId }); await load({ provider, modelId: activeModelId }); setNotice( isOpenAiCompatible ? t("Model updated.") : t("Now using {label}.", { label: selected.label }), ); } catch (err) { setError(err instanceof Error ? err.message : t("Could not change the default model")); } finally { setPending(null); } } async function connectKey() { if (!selected) return; if (isOpenAiCompatible) { if (!effectiveBaseUrl || !modelId.trim()) return; } else if (!apiKey.trim()) { return; } const parsedMaxImagesPerPrompt = parseModelMaxImagesPerPrompt( maxImagesPerPrompt, supportsImages, ); if (supportsImages && maxImagesPerPrompt.trim() && parsedMaxImagesPerPrompt === undefined) { setError(t("Enter a whole number from 1 to 1000 for the image limit.")); return; } const maxImagesPerPromptInput = supportsImages && !maxImagesPerPrompt.trim() ? null : parsedMaxImagesPerPrompt; const parsedMaxTokens = parseModelMaxTokens(maxTokens); if (parsedMaxTokens === undefined) { setError( t("Enter a whole number from 1 to {max} for maximum output tokens.", { max: MAX_MODEL_MAX_TOKENS, }), ); return; } const parsedContextWindow = parseModelContextWindow(contextWindow); if (parsedContextWindow === undefined) { setError( t("Enter a whole number from 1 to {max} for the context limit.", { max: MAX_MODEL_CONTEXT_WINDOW, }), ); return; } setError(null); setNotice(null); setPending("connect"); try { await rpc( "models/connect", isOpenAiCompatible ? { provider: selected.provider, baseUrl: effectiveBaseUrl, modelId: modelId.trim(), reasoning, thinkingLevel: reasoning ? thinkingLevel : null, maxTokens: parsedMaxTokens, contextWindow: parsedContextWindow, supportsImages, maxImagesPerPrompt: maxImagesPerPromptInput, apiKey: apiKey.trim() || undefined, label: selected.providerName ?? selected.provider, } : { provider: selected.provider, apiKey: apiKey.trim(), modelId: selected.id, label: selected.providerName ?? selected.provider, }, ); setApiKey(""); await load({ provider, modelId }); setNotice( isOpenAiCompatible ? t("Saved.") : t("Connected and using {label}.", { label: selected.label }), ); } catch (err) { setError(err instanceof Error ? err.message : t("Could not connect this provider")); } finally { setPending(null); } } async function finishSubscriptionSignIn(loginId: string, controller: AbortController) { await waitForModelOAuth(loginId, controller.signal); if (controller.signal.aborted) return; await rpc("models/finishOAuth", { loginId }, { signal: controller.signal }); if (controller.signal.aborted) return; oauthLoginIdRef.current = null; setOauth(null); await load({ provider, modelId }); if (controller.signal.aborted) return; setNotice(t("Connected and using {label}.", { label: selected?.label ?? t("this model") })); } async function startSubscriptionSignIn() { if (!selected) return; setError(null); setNotice(null); setOauthPending(true); const controller = new AbortController(); oauthAbortRef.current = controller; let waitingForCode = false; try { const started = await rpc( "models/beginOAuth", { provider: selected.provider, modelId: selected.id, label: selected.providerName ?? selected.provider, }, { signal: controller.signal }, ); if (controller.signal.aborted) return; oauthLoginIdRef.current = started.loginId; setPasteCode(""); setOauth(started); await Linking.openURL(started.verificationUri); waitingForCode = started.mode === "auth-url"; if (!waitingForCode) await finishSubscriptionSignIn(started.loginId, controller); } catch (err) { if (controller.signal.aborted) return; const loginId = oauthLoginIdRef.current; oauthLoginIdRef.current = null; if (loginId) void rpc("models/cancelOAuth", { loginId }).catch(() => undefined); setError(err instanceof Error ? err.message : t("Could not start sign-in")); setOauth(null); } finally { if (!waitingForCode) { finishModelOAuthAttempt(oauthAbortRef, controller, () => setOauthPending(false)); } } } async function submitOAuthCode() { if (oauth?.mode !== "auth-url" || oauthCodeSubmittingRef.current) return; const controller = oauthAbortRef.current; const code = pasteCode.trim(); if (!controller || !code) return; oauthCodeSubmittingRef.current = true; setPasteCode(""); setError(null); let submitted = false; let retryable = false; try { await rpc( "models/submitOAuthCode", { loginId: oauth.loginId, code }, { signal: controller.signal, }, ); submitted = true; await finishSubscriptionSignIn(oauth.loginId, controller); } catch (err) { if (controller.signal.aborted) return; if (submitted) { oauthLoginIdRef.current = null; setOauth(null); void rpc("models/cancelOAuth", { loginId: oauth.loginId }).catch(() => undefined); } else { retryable = true; setPasteCode(code); } setError(err instanceof Error ? err.message : t("Could not finish sign-in")); } finally { oauthCodeSubmittingRef.current = false; if (!retryable) { finishModelOAuthAttempt(oauthAbortRef, controller, () => setOauthPending(false)); } } } if (loading && catalog.length === 0) { return ( ); } return ( {t("Active model")} {currentEntry?.label ?? me?.defaultModel ?? t("Deployment default")} {currentEntry?.providerName ?? me?.defaultProvider ?? t("Configured by deployment")} {error ? {error} : null} {notice ? {notice} : null} {t("Providers")} {visibleGroups.map((group) => { const connected = credentials.some((entry) => entry.provider === group.id); return ( chooseProvider(group.id)} style={({ pressed }) => [ styles.providerRow, group.id === provider && styles.selectedRow, pressed && styles.pressed, ]} > {group.name} {t(group.entries.length === 1 ? "{count} model" : "{count} models", { count: group.entries.length, })} {connected ? {t("Connected")} : null} ); })} {groups.length > featuredProviders.length ? ( setShowAllProviders((value) => !value)} style={styles.providerRow} > {showAllProviders ? t("Show less") : t("Show more")} ) : null} {selected ? ( <> {!isOpenAiCompatible ? {t("Model")} : null} {isOpenAiCompatible ? ( <> {t("Server URL")} setShowEndpointHelp((visible) => !visible)} > {t("Setup help")} {showEndpointHelp ? ( {t(OPENAI_COMPATIBLE_BASE_URL_HINT)} ) : null} void probeServerModels()} style={({ pressed }) => [ styles.outlineButton, (busy || probing || !effectiveBaseUrl) && styles.disabled, pressed && styles.pressed, ]} > {probing ? t("Finding…") : t("Find models")} {t("Model")} {probeModels.length && probeModels.includes(modelId) ? ( {probeModels.map((entry) => ( setModelId(entry)} style={({ pressed }) => [ styles.modelRow, entry === modelId && styles.selectedRow, probing && styles.disabled, pressed && styles.pressed, ]} > {entry === modelId ? : null} {entry} ))} setModelId("")} style={({ pressed }) => [ styles.modelRow, probing && styles.disabled, pressed && styles.pressed, ]} > {t("Other model…")} ) : ( <> {probeModels.length ? ( setModelId(probeModels[0] ?? "")} > {t("Use a found model")} ) : null} )} setShowAdvanced((visible) => !visible)} > {t("Advanced")} {showAdvanced ? ( {t("Supports thinking")} { setReasoning(value); if (!value) setThinkingLevel(null); }} disabled={busy} /> ) : null} {showAdvanced && reasoning ? ( { presentMessageActionSheet({ title: t("Reasoning effort"), cancel: t("Cancel"), more: t("More"), colorScheme, actions: [ { text: t("Default"), onPress: () => setThinkingLevel(null), }, ...THINKING_LEVEL_OPTIONS.map((level) => ({ text: thinkingLevelLabel(level, t), onPress: () => setThinkingLevel(level), })), ], }); }} style={styles.modelRow} > {t("Reasoning effort")} {thinkingLevel ? thinkingLevelLabel(thinkingLevel, t) : t("Default")} ) : null} {showAdvanced ? ( {t("Context limit")} ) : null} {showAdvanced ? ( {t("Maximum output tokens")} ) : null} {showAdvanced ? ( {t("Supports images")} ) : null} {showAdvanced && supportsImages ? ( {t("Maximum images per request")} ) : null} ) : ( {modelsForProvider.map((entry) => ( { cancelOAuth(); setModelId(entry.id); setError(null); setNotice(null); }} style={({ pressed }) => [ styles.modelRow, entry.id === selected.id && styles.selectedRow, pressed && styles.pressed, ]} > {entry.id === selected.id ? : null} {entry.label} ))} )} {!isOpenAiCompatible && selected.billing ? ( {selected.billing} ) : null} {!isOpenAiCompatible ? ( {t("Personal credential")} {credential ? t("Connected · {label}", { label: credential.label }) : t("Not connected")} {credential ? t("Stored securely. Never shown here.") : t("Connect this provider to use it as your personal model.")} ) : null} {subscriptionSignIn ? ( oauth ? ( {oauth.mode === "auth-url" ? ( <> {t("Finish signing in in your browser:")} void Linking.openURL(oauth.verificationUri)}> {oauth.verificationUri} {t("The final page may not load. Paste its URL or code here.")} void submitOAuthCode()} style={({ pressed }) => [ styles.outlineButton, pressed && styles.pressed, !pasteCode.trim() && styles.disabled, ]} > {t("Submit")} {t("Waiting for sign-in…")} ) : ( <> {t("Enter this code in your browser:")} void Linking.openURL(oauth.verificationUri)}> {oauth.verificationUri} {oauth.userCode} {t("Waiting for sign-in…")} )} ) : ( void startSubscriptionSignIn()} style={({ pressed }) => [ styles.outlineButton, pressed && styles.pressed, busy && styles.disabled, ]} > {oauthPending ? t("Starting…") : (selected.oauthLabel ?? t("Sign in"))} ) ) : null} {acceptsKey ? ( {isOpenAiCompatible ? ( <> setShowApiKey((visible) => !visible)} > {t("API key")} {showApiKey ? ( ) : null} ) : ( <> {credential ? t("Replace API key") : subscriptionSignIn ? t("Or connect an API key") : t("API key")} )} void connectKey()} style={({ pressed }) => [ styles.primaryButton, (busy || (isOpenAiCompatible ? !openAiCompatibleReady : apiKey.trim().length < 8)) && styles.disabled, pressed && styles.pressed, ]} > {pending === "connect" ? t("Saving…") : isOpenAiCompatible ? t("Save") : credential ? t("Replace API key") : t("Connect API key")} ) : null} {selected.auth === "oauth" && !subscriptionSignIn ? ( {t( "This subscription sign-in is not available in evOo yet. Use a deployment credential or choose another provider.", )} ) : null} {credential && !isActive ? ( void setModelDefault()} style={({ pressed }) => [ styles.primaryButton, busy && styles.disabled, pressed && styles.pressed, ]} > {pending === "default" ? t("Switching…") : t("Use this model")} ) : null} ) : null} ); } function createModelsStyles() { const tokens = mobileTokens(); return StyleSheet.create({ screen: { flex: 1, backgroundColor: native.page, }, centered: { alignItems: "center", justifyContent: "center", }, content: { padding: 20, gap: 12, paddingBottom: 40, }, activeCard: { borderRadius: 16, backgroundColor: native.fill, padding: 18, marginBottom: 8, }, eyebrow: { color: native.tertiaryLabel, fontSize: 12, textTransform: "uppercase", letterSpacing: 1, }, activeModel: { color: native.label, fontSize: 19, fontWeight: "600", marginTop: 6, }, secondary: { color: native.secondaryLabel, fontSize: 14, lineHeight: 20, marginTop: 4, }, sectionTitle: { color: native.secondaryLabel, fontSize: 14, marginTop: 8, marginBottom: 2, }, card: { borderRadius: 14, backgroundColor: native.fill, overflow: "hidden", }, providerRow: { minHeight: 62, paddingHorizontal: 16, paddingVertical: 10, flexDirection: "row", alignItems: "center", gap: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: native.fillPressed, }, providerCopy: { flex: 1, }, providerName: { color: native.label, fontSize: 16, fontWeight: "600", }, connected: { color: tokens.success, fontSize: 13, }, modelRow: { minHeight: 54, paddingHorizontal: 16, paddingVertical: 10, flexDirection: "row", alignItems: "center", gap: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: native.fillPressed, }, radio: { width: 20, height: 20, borderRadius: 10, borderWidth: 1, borderColor: native.secondaryLabel, alignItems: "center", justifyContent: "center", }, radioDot: { width: 10, height: 10, borderRadius: 5, backgroundColor: native.label, }, modelLabel: { flex: 1, color: native.label, fontSize: 15, }, selectedRow: { backgroundColor: tokens.accent, }, billing: { color: native.secondaryLabel, fontSize: 13, lineHeight: 19, marginTop: 2, }, hint: { color: native.secondaryLabel, fontSize: 13, lineHeight: 19, marginTop: 4, }, helpLabel: { color: native.secondaryLabel, fontSize: 13, marginTop: 8, textDecorationLine: "underline", }, credentialCard: { borderRadius: 14, borderWidth: StyleSheet.hairlineWidth, borderColor: native.fillPressed, padding: 16, marginTop: 8, }, credentialTitle: { color: native.label, fontSize: 16, marginTop: 6, }, oauthCard: { borderRadius: 14, borderWidth: StyleSheet.hairlineWidth, borderColor: native.fillPressed, padding: 16, marginTop: 8, }, link: { color: native.label, fontSize: 14, textDecorationLine: "underline", marginTop: 6, }, code: { color: native.label, fontFamily: "monospace", fontSize: 24, letterSpacing: 3, marginTop: 10, marginBottom: 2, }, keySection: { marginTop: 4, }, keyInput: { height: 48, borderRadius: 12, backgroundColor: native.fill, color: native.label, paddingHorizontal: 14, marginTop: 4, fontSize: 16, }, maxImagesInput: { width: 72, height: 40, marginTop: 0, textAlign: "center", }, primaryButton: { minHeight: 48, borderRadius: 12, alignItems: "center", justifyContent: "center", backgroundColor: native.label, marginTop: 12, paddingHorizontal: 16, }, primaryLabel: { color: native.page, fontSize: 16, fontWeight: "700", }, outlineButton: { minHeight: 48, borderRadius: 12, borderWidth: StyleSheet.hairlineWidth, borderColor: native.fillPressed, alignItems: "center", justifyContent: "center", marginTop: 12, paddingHorizontal: 16, }, outlineLabel: { color: native.label, fontSize: 16, fontWeight: "600", }, error: { color: tokens.destructive, fontSize: 14, marginTop: 4, }, notice: { color: tokens.success, fontSize: 14, marginTop: 4, }, disabled: { opacity: 0.45, }, pressed: { opacity: 0.7, }, }); }