diff options
| author | Evan Simkowitz <esimkowitz@users.noreply.github.com> | 2025-12-14 16:39:25 -0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-15 00:39:25 +0000 |
| commit | 7f4202afd73105b850498b55ad66922b3505f0e3 (patch) | |
| tree | a45f9f1b2599f4c9925e36dc51563b06ba6854ac /apps/mobile | |
| parent | 6db14ac492cd5d9e26d0d986513771f14faa7fd0 (diff) | |
| download | karakeep-7f4202afd73105b850498b55ad66922b3505f0e3.tar.zst | |
feat: Add unified reader settings with local overrides (#2230)
* Add initial impl
* fix some format inconsistencies, add indicator in user settings when local is out of sync
* Fix sliders in user settings, unify constants and formatting
* address CodeRabbit suggestions
* add mobile implementation
* address coderabbit nitpicks
* fix responsiveness of the reader settings popover
* Move more of the web UI strings to i18n
* update translations for more coverage
* remove duplicate logic/definitions
* fix android font family
* add shared reading setting hook between web and mobile
* unify reader settings context for both web and mobile
* remove unused export
* address coderabbit suggestions
* fix tests
Diffstat (limited to 'apps/mobile')
| -rw-r--r-- | apps/mobile/app/dashboard/(tabs)/settings.tsx | 12 | ||||
| -rw-r--r-- | apps/mobile/app/dashboard/_layout.tsx | 8 | ||||
| -rw-r--r-- | apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx | 27 | ||||
| -rw-r--r-- | apps/mobile/app/dashboard/settings/reader-settings.tsx | 264 | ||||
| -rw-r--r-- | apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx | 43 | ||||
| -rw-r--r-- | apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx | 6 | ||||
| -rw-r--r-- | apps/mobile/lib/providers.tsx | 5 | ||||
| -rw-r--r-- | apps/mobile/lib/readerSettings.tsx | 93 | ||||
| -rw-r--r-- | apps/mobile/lib/settings.ts | 8 |
9 files changed, 443 insertions, 23 deletions
diff --git a/apps/mobile/app/dashboard/(tabs)/settings.tsx b/apps/mobile/app/dashboard/(tabs)/settings.tsx index 76216e00..7c1e00d6 100644 --- a/apps/mobile/app/dashboard/(tabs)/settings.tsx +++ b/apps/mobile/app/dashboard/(tabs)/settings.tsx @@ -88,6 +88,18 @@ export default function Dashboard() { </Link> </View> <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2"> + <Link + asChild + href="/dashboard/settings/reader-settings" + className="flex-1" + > + <Pressable className="flex flex-row justify-between"> + <Text>Reader Text Settings</Text> + <ChevronRight /> + </Pressable> + </Link> + </View> + <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2"> <Text>Show note preview in bookmark</Text> <Switch value={settings.showNotes} diff --git a/apps/mobile/app/dashboard/_layout.tsx b/apps/mobile/app/dashboard/_layout.tsx index eb1cbe4b..260071f0 100644 --- a/apps/mobile/app/dashboard/_layout.tsx +++ b/apps/mobile/app/dashboard/_layout.tsx @@ -144,6 +144,14 @@ export default function Dashboard() { headerBackTitle: "Back", }} /> + <Stack.Screen + name="settings/reader-settings" + options={{ + title: "Reader Settings", + headerTitle: "Reader Settings", + headerBackTitle: "Back", + }} + /> </StyledStack> ); } diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx index 7bf0f118..8fd04115 100644 --- a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx +++ b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { KeyboardAvoidingView } from "react-native"; +import { KeyboardAvoidingView, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Stack, useLocalSearchParams } from "expo-router"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import BookmarkAssetView from "@/components/bookmarks/BookmarkAssetView"; import BookmarkLinkTypeSelector, { BookmarkLinkType, @@ -13,12 +13,14 @@ import FullPageError from "@/components/FullPageError"; import FullPageSpinner from "@/components/ui/FullPageSpinner"; import useAppSettings from "@/lib/settings"; import { api } from "@/lib/trpc"; +import { Settings } from "lucide-react-native"; import { useColorScheme } from "nativewind"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; export default function BookmarkView() { const insets = useSafeAreaInsets(); + const router = useRouter(); const { slug } = useLocalSearchParams(); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; @@ -87,11 +89,22 @@ export default function BookmarkView() { headerTintColor: isDark ? "#fff" : "#000", headerRight: () => bookmark.content.type === BookmarkTypes.LINK ? ( - <BookmarkLinkTypeSelector - type={bookmarkLinkType} - onChange={(type) => setBookmarkLinkType(type)} - bookmark={bookmark} - /> + <View className="flex-row items-center gap-3"> + {bookmarkLinkType === "reader" && ( + <Pressable + onPress={() => + router.push("/dashboard/settings/reader-settings") + } + > + <Settings size={20} color="gray" /> + </Pressable> + )} + <BookmarkLinkTypeSelector + type={bookmarkLinkType} + onChange={(type) => setBookmarkLinkType(type)} + bookmark={bookmark} + /> + </View> ) : undefined, }} /> diff --git a/apps/mobile/app/dashboard/settings/reader-settings.tsx b/apps/mobile/app/dashboard/settings/reader-settings.tsx new file mode 100644 index 00000000..6c522557 --- /dev/null +++ b/apps/mobile/app/dashboard/settings/reader-settings.tsx @@ -0,0 +1,264 @@ +import { useEffect, useState } from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import { Slider } from "react-native-awesome-slider"; +import { runOnJS, useSharedValue } from "react-native-reanimated"; +import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView"; +import { Divider } from "@/components/ui/Divider"; +import { Text } from "@/components/ui/Text"; +import { MOBILE_FONT_FAMILIES, useReaderSettings } from "@/lib/readerSettings"; +import { useColorScheme } from "@/lib/useColorScheme"; +import { Check, RotateCcw } from "lucide-react-native"; + +import { + formatFontFamily, + formatFontSize, + formatLineHeight, + READER_SETTING_CONSTRAINTS, +} from "@karakeep/shared/types/readers"; +import { ZReaderFontFamily } from "@karakeep/shared/types/users"; + +export default function ReaderSettingsPage() { + const { isDarkColorScheme: isDark } = useColorScheme(); + + const { + settings, + localOverrides, + hasLocalOverrides, + hasServerDefaults, + updateLocal, + clearAllLocal, + saveAsDefault, + clearAllDefaults, + } = useReaderSettings(); + + const { + fontSize: effectiveFontSize, + lineHeight: effectiveLineHeight, + fontFamily: effectiveFontFamily, + } = settings; + + // Shared values for sliders + const fontSizeProgress = useSharedValue<number>(effectiveFontSize); + const fontSizeMin = useSharedValue<number>( + READER_SETTING_CONSTRAINTS.fontSize.min, + ); + const fontSizeMax = useSharedValue<number>( + READER_SETTING_CONSTRAINTS.fontSize.max, + ); + + const lineHeightProgress = useSharedValue<number>(effectiveLineHeight); + const lineHeightMin = useSharedValue<number>( + READER_SETTING_CONSTRAINTS.lineHeight.min, + ); + const lineHeightMax = useSharedValue<number>( + READER_SETTING_CONSTRAINTS.lineHeight.max, + ); + + // Display values for showing rounded values while dragging + const [displayFontSize, setDisplayFontSize] = useState(effectiveFontSize); + const [displayLineHeight, setDisplayLineHeight] = + useState(effectiveLineHeight); + + // Sync slider progress and display values with effective settings + useEffect(() => { + fontSizeProgress.value = effectiveFontSize; + setDisplayFontSize(effectiveFontSize); + }, [effectiveFontSize]); + + useEffect(() => { + lineHeightProgress.value = effectiveLineHeight; + setDisplayLineHeight(effectiveLineHeight); + }, [effectiveLineHeight]); + + const handleFontFamilyChange = (fontFamily: ZReaderFontFamily) => { + updateLocal({ fontFamily }); + }; + + const handleFontSizeChange = (value: number) => { + updateLocal({ fontSize: Math.round(value) }); + }; + + const handleLineHeightChange = (value: number) => { + updateLocal({ lineHeight: Math.round(value * 10) / 10 }); + }; + + const handleSaveAsDefault = () => { + saveAsDefault(); + // Note: clearAllLocal is called automatically in the shared hook's onSuccess + }; + + const handleClearLocalOverrides = () => { + clearAllLocal(); + }; + + const handleClearServerDefaults = () => { + clearAllDefaults(); + }; + + const fontFamilyOptions: ZReaderFontFamily[] = ["serif", "sans", "mono"]; + + return ( + <CustomSafeAreaView> + <ScrollView + className="w-full" + contentContainerClassName="items-center gap-4 px-4 py-2" + > + {/* Font Family Selection */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Font Family + {localOverrides.fontFamily !== undefined && ( + <Text className="text-blue-500"> (local)</Text> + )} + </Text> + <View className="w-full rounded-lg bg-card px-4 py-2"> + {fontFamilyOptions.map((fontFamily, index) => { + const isChecked = effectiveFontFamily === fontFamily; + return ( + <View key={fontFamily}> + <Pressable + onPress={() => handleFontFamilyChange(fontFamily)} + className="flex flex-row items-center justify-between py-2" + > + <Text + style={{ + fontFamily: MOBILE_FONT_FAMILIES[fontFamily], + }} + > + {formatFontFamily(fontFamily)} + </Text> + {isChecked && <Check color="rgb(0, 122, 255)" />} + </Pressable> + {index < fontFamilyOptions.length - 1 && ( + <Divider orientation="horizontal" className="h-0.5" /> + )} + </View> + ); + })} + </View> + </View> + + {/* Font Size */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Font Size ({formatFontSize(displayFontSize)}) + {localOverrides.fontSize !== undefined && ( + <Text className="text-blue-500"> (local)</Text> + )} + </Text> + <View className="flex w-full flex-row items-center gap-3 rounded-lg bg-card px-4 py-3"> + <Text className="text-muted-foreground"> + {READER_SETTING_CONSTRAINTS.fontSize.min} + </Text> + <View className="flex-1"> + <Slider + progress={fontSizeProgress} + minimumValue={fontSizeMin} + maximumValue={fontSizeMax} + renderBubble={() => null} + onValueChange={(value) => { + "worklet"; + runOnJS(setDisplayFontSize)(Math.round(value)); + }} + onSlidingComplete={(value) => + handleFontSizeChange(Math.round(value)) + } + /> + </View> + <Text className="text-muted-foreground"> + {READER_SETTING_CONSTRAINTS.fontSize.max} + </Text> + </View> + </View> + + {/* Line Height */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Line Height ({formatLineHeight(displayLineHeight)}) + {localOverrides.lineHeight !== undefined && ( + <Text className="text-blue-500"> (local)</Text> + )} + </Text> + <View className="flex w-full flex-row items-center gap-3 rounded-lg bg-card px-4 py-3"> + <Text className="text-muted-foreground"> + {READER_SETTING_CONSTRAINTS.lineHeight.min} + </Text> + <View className="flex-1"> + <Slider + progress={lineHeightProgress} + minimumValue={lineHeightMin} + maximumValue={lineHeightMax} + renderBubble={() => null} + onValueChange={(value) => { + "worklet"; + runOnJS(setDisplayLineHeight)(Math.round(value * 10) / 10); + }} + onSlidingComplete={handleLineHeightChange} + /> + </View> + <Text className="text-muted-foreground"> + {READER_SETTING_CONSTRAINTS.lineHeight.max} + </Text> + </View> + </View> + + {/* Preview */} + <View className="w-full"> + <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground"> + Preview + </Text> + <View className="w-full rounded-lg bg-card px-4 py-3"> + <Text + style={{ + fontFamily: MOBILE_FONT_FAMILIES[effectiveFontFamily], + fontSize: effectiveFontSize, + lineHeight: effectiveFontSize * effectiveLineHeight, + }} + className="text-foreground" + > + The quick brown fox jumps over the lazy dog. Pack my box with five + dozen liquor jugs. How vexingly quick daft zebras jump! + </Text> + </View> + </View> + + <Divider orientation="horizontal" className="my-2 w-full" /> + + {/* Save as Default */} + <Pressable + onPress={handleSaveAsDefault} + disabled={!hasLocalOverrides} + className="w-full rounded-lg bg-card px-4 py-3" + > + <Text + className={`text-center ${hasLocalOverrides ? "text-blue-500" : "text-muted-foreground"}`} + > + Save as Default (All Devices) + </Text> + </Pressable> + + {/* Clear Local */} + {hasLocalOverrides && ( + <Pressable + onPress={handleClearLocalOverrides} + className="flex w-full flex-row items-center justify-center gap-2 rounded-lg bg-card px-4 py-3" + > + <RotateCcw size={16} color={isDark ? "#9ca3af" : "#6b7280"} /> + <Text className="text-muted-foreground">Clear Local Overrides</Text> + </Pressable> + )} + + {/* Clear Server */} + {hasServerDefaults && ( + <Pressable + onPress={handleClearServerDefaults} + className="flex w-full flex-row items-center justify-center gap-2 rounded-lg bg-card px-4 py-3" + > + <RotateCcw size={16} color={isDark ? "#9ca3af" : "#6b7280"} /> + <Text className="text-muted-foreground">Clear Server Defaults</Text> + </Pressable> + )} + </ScrollView> + </CustomSafeAreaView> + ); +} diff --git a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx index 730bcd08..e0b592d6 100644 --- a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx +++ b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx @@ -5,6 +5,7 @@ import WebView from "react-native-webview"; import { WebViewSourceUri } from "react-native-webview/lib/WebViewTypes"; import { Text } from "@/components/ui/Text"; import { useAssetUrl } from "@/lib/hooks"; +import { useReaderSettings, WEBVIEW_FONT_FAMILIES } from "@/lib/readerSettings"; import { api } from "@/lib/trpc"; import { useColorScheme } from "@/lib/useColorScheme"; @@ -38,6 +39,7 @@ export function BookmarkLinkReaderPreview({ bookmark: ZBookmark; }) { const { isDarkColorScheme: isDark } = useColorScheme(); + const { settings: readerSettings } = useReaderSettings(); const { data: bookmarkWithContent, @@ -61,6 +63,10 @@ export function BookmarkLinkReaderPreview({ throw new Error("Wrong content type rendered"); } + const fontFamily = WEBVIEW_FONT_FAMILIES[readerSettings.fontFamily]; + const fontSize = readerSettings.fontSize; + const lineHeight = readerSettings.lineHeight; + return ( <View className="flex-1 bg-background"> <WebView @@ -73,8 +79,9 @@ export function BookmarkLinkReaderPreview({ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; - line-height: 1.6; + font-family: ${fontFamily}; + font-size: ${fontSize}px; + line-height: ${lineHeight}; color: ${isDark ? "#e5e7eb" : "#374151"}; margin: 0; padding: 16px; @@ -85,17 +92,29 @@ export function BookmarkLinkReaderPreview({ img { max-width: 100%; height: auto; border-radius: 8px; } a { color: #3b82f6; text-decoration: none; } a:hover { text-decoration: underline; } - blockquote { - border-left: 4px solid ${isDark ? "#374151" : "#e5e7eb"}; - margin: 1em 0; - padding-left: 1em; - color: ${isDark ? "#9ca3af" : "#6b7280"}; + blockquote { + border-left: 4px solid ${isDark ? "#374151" : "#e5e7eb"}; + margin: 1em 0; + padding-left: 1em; + color: ${isDark ? "#9ca3af" : "#6b7280"}; + } + pre, code { + font-family: ui-monospace, Menlo, Monaco, 'Courier New', monospace; + background: ${isDark ? "#1f2937" : "#f3f4f6"}; + } + pre { + padding: 1em; + border-radius: 6px; + overflow-x: auto; + } + code { + padding: 0.2em 0.4em; + border-radius: 3px; + font-size: 0.9em; } - pre { - background: ${isDark ? "#1f2937" : "#f3f4f6"}; - padding: 1em; - border-radius: 6px; - overflow-x: auto; + pre code { + padding: 0; + background: none; } </style> </head> diff --git a/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx b/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx index 58cbcc8d..c7fd4be3 100644 --- a/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx +++ b/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx @@ -43,7 +43,7 @@ export default function BookmarkLinkTypeSelector({ }: BookmarkLinkTypeSelectorProps) { const availableTypes = getAvailableViewTypes(bookmark); - const allActions = [ + const viewActions = [ { id: "reader" as const, title: "Reader View", @@ -66,7 +66,7 @@ export default function BookmarkLinkTypeSelector({ }, ]; - const availableActions = allActions.filter((action) => + const availableViewActions = viewActions.filter((action) => availableTypes.includes(action.id), ); @@ -76,7 +76,7 @@ export default function BookmarkLinkTypeSelector({ Haptics.selectionAsync(); onChange(nativeEvent.event as BookmarkLinkType); }} - actions={availableActions} + actions={availableViewActions} shouldOpenOnLongPress={false} > <ChevronDown onPress={() => Haptics.selectionAsync()} color="gray" /> diff --git a/apps/mobile/lib/providers.tsx b/apps/mobile/lib/providers.tsx index 938b8aeb..36ed7e71 100644 --- a/apps/mobile/lib/providers.tsx +++ b/apps/mobile/lib/providers.tsx @@ -4,6 +4,7 @@ import { ToastProvider } from "@/components/ui/Toast"; import { TRPCProvider } from "@karakeep/shared-react/providers/trpc-provider"; +import { ReaderSettingsProvider } from "./readerSettings"; import useAppSettings from "./settings"; export function Providers({ children }: { children: React.ReactNode }) { @@ -20,7 +21,9 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( <TRPCProvider settings={settings}> - <ToastProvider>{children}</ToastProvider> + <ReaderSettingsProvider> + <ToastProvider>{children}</ToastProvider> + </ReaderSettingsProvider> </TRPCProvider> ); } diff --git a/apps/mobile/lib/readerSettings.tsx b/apps/mobile/lib/readerSettings.tsx new file mode 100644 index 00000000..9a3fc835 --- /dev/null +++ b/apps/mobile/lib/readerSettings.tsx @@ -0,0 +1,93 @@ +import { ReactNode, useCallback } from "react"; +import { Platform } from "react-native"; + +import { + ReaderSettingsProvider as BaseReaderSettingsProvider, + useReaderSettingsContext, +} from "@karakeep/shared-react/hooks/reader-settings"; +import { ReaderSettingsPartial } from "@karakeep/shared/types/readers"; +import { ZReaderFontFamily } from "@karakeep/shared/types/users"; + +import { useSettings } from "./settings"; + +// Mobile-specific font families for native Text components +// On Android, use generic font family names: "serif", "sans-serif", "monospace" +// On iOS, use specific font names like "Georgia" and "Courier" +// Note: undefined means use the system default font +export const MOBILE_FONT_FAMILIES: Record< + ZReaderFontFamily, + string | undefined +> = Platform.select({ + android: { + serif: "serif", + sans: undefined, + mono: "monospace", + }, + default: { + serif: "Georgia", + sans: undefined, + mono: "Courier", + }, +})!; + +// Font families for WebView HTML content (CSS font stacks) +export const WEBVIEW_FONT_FAMILIES: Record<ZReaderFontFamily, string> = { + serif: "Georgia, 'Times New Roman', serif", + sans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif", + mono: "ui-monospace, Menlo, Monaco, 'Courier New', monospace", +} as const; + +/** + * Mobile-specific provider for reader settings. + * Wraps the shared provider with mobile storage callbacks. + */ +export function ReaderSettingsProvider({ children }: { children: ReactNode }) { + // Read from zustand store directly to keep callback stable (empty deps). + const getLocalOverrides = useCallback((): ReaderSettingsPartial => { + const currentSettings = useSettings.getState().settings.settings; + return { + fontSize: currentSettings.readerFontSize, + lineHeight: currentSettings.readerLineHeight, + fontFamily: currentSettings.readerFontFamily, + }; + }, []); + + const saveLocalOverrides = useCallback((overrides: ReaderSettingsPartial) => { + const currentSettings = useSettings.getState().settings.settings; + // Remove reader settings keys first, then add back only defined ones + const { + readerFontSize: _fs, + readerLineHeight: _lh, + readerFontFamily: _ff, + ...rest + } = currentSettings; + + const newSettings = { ...rest }; + if (overrides.fontSize !== undefined) { + (newSettings as typeof currentSettings).readerFontSize = + overrides.fontSize; + } + if (overrides.lineHeight !== undefined) { + (newSettings as typeof currentSettings).readerLineHeight = + overrides.lineHeight; + } + if (overrides.fontFamily !== undefined) { + (newSettings as typeof currentSettings).readerFontFamily = + overrides.fontFamily; + } + + useSettings.getState().setSettings(newSettings); + }, []); + + return ( + <BaseReaderSettingsProvider + getLocalOverrides={getLocalOverrides} + saveLocalOverrides={saveLocalOverrides} + > + {children} + </BaseReaderSettingsProvider> + ); +} + +// Re-export the context hook as useReaderSettings for mobile consumers +export { useReaderSettingsContext as useReaderSettings }; diff --git a/apps/mobile/lib/settings.ts b/apps/mobile/lib/settings.ts index 40a33976..745c778d 100644 --- a/apps/mobile/lib/settings.ts +++ b/apps/mobile/lib/settings.ts @@ -2,6 +2,8 @@ import * as SecureStore from "expo-secure-store"; import { z } from "zod"; import { create } from "zustand"; +import { zReaderFontFamilySchema } from "@karakeep/shared/types/users"; + const SETTING_NAME = "settings"; const zSettingsSchema = z.object({ @@ -16,6 +18,10 @@ const zSettingsSchema = z.object({ .default("reader"), showNotes: z.boolean().optional().default(false), customHeaders: z.record(z.string(), z.string()).optional().default({}), + // Reader settings (local device overrides) + readerFontSize: z.number().int().min(12).max(24).optional(), + readerLineHeight: z.number().min(1.2).max(2.5).optional(), + readerFontFamily: zReaderFontFamilySchema.optional(), }); export type Settings = z.infer<typeof zSettingsSchema>; @@ -73,3 +79,5 @@ export default function useAppSettings() { return { ...settings, setSettings, load }; } + +export { useSettings }; |
