diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 4c52df2d3b..a7be96d38f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -50,6 +50,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ favicon: iconPath, }, plugins: [ + "expo-localization", [ "expo-router", { diff --git a/apps/mobile/global.d.ts b/apps/mobile/global.d.ts index 8e9d706bfd..9aed8ba265 100644 --- a/apps/mobile/global.d.ts +++ b/apps/mobile/global.d.ts @@ -1,7 +1,8 @@ import type { DOMProps } from "expo/dom" import type { FC } from "react" +import type WebView from "react-native-webview" declare global { - export type WebComponent

= FC

+ export type WebComponent

= FC

> } export {} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index fc809f4867..d53cfa95b7 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@follow/mobile", - "version": "1.0.0", + "version": "0.1.0", "private": true, "main": "src/main.tsx", "scripts": { @@ -43,7 +43,7 @@ "expo-blur": "~14.0.1", "expo-build-properties": "^0.13.1", "expo-clipboard": "~7.0.0", - "expo-constants": "~17.0.3", + "expo-constants": "~17.0.4", "expo-dev-client": "^5.0.9", "expo-file-system": "~18.0.6", "expo-font": "~13.0.1", @@ -51,6 +51,7 @@ "expo-image": "~2.0.3", "expo-linear-gradient": "~14.0.1", "expo-linking": "~7.0.3", + "expo-localization": "~16.0.1", "expo-router": "4.0.11", "expo-secure-store": "^14.0.1", "expo-sharing": "~13.0.0", diff --git a/apps/mobile/src/atoms/settings/general.ts b/apps/mobile/src/atoms/settings/general.ts new file mode 100644 index 0000000000..756503704d --- /dev/null +++ b/apps/mobile/src/atoms/settings/general.ts @@ -0,0 +1,44 @@ +import type { GeneralSettings } from "@/src/interfaces/settings/general" + +import { createSettingAtom } from "./internal/helper" + +const createDefaultSettings = (): GeneralSettings => ({ + // App + + language: "en", + translationLanguage: "zh-CN", + + // Data control + + sendAnonymousData: true, + + autoGroup: true, + + // view + unreadOnly: true, + // mark unread + scrollMarkUnread: true, + + renderMarkUnread: false, + // UX + groupByDate: true, + autoExpandLongSocialMedia: false, + + // Secure + jumpOutLinkWarn: true, + // TTS + voice: "en-US-AndrewMultilingualNeural", +}) + +export const { + useSettingKey: useGeneralSettingKey, + useSettingSelector: useGeneralSettingSelector, + useSettingKeys: useGeneralSettingKeys, + setSetting: setGeneralSetting, + clearSettings: clearGeneralSettings, + initializeDefaultSettings: initializeDefaultGeneralSettings, + getSettings: getGeneralSettings, + useSettingValue: useGeneralSettingValue, + + settingAtom: __generalSettingAtom, +} = createSettingAtom("general", createDefaultSettings) diff --git a/apps/mobile/src/atoms/settings/internal/helper.ts b/apps/mobile/src/atoms/settings/internal/helper.ts new file mode 100644 index 0000000000..d6d37fce4e --- /dev/null +++ b/apps/mobile/src/atoms/settings/internal/helper.ts @@ -0,0 +1,160 @@ +import { useRefValue } from "@follow/hooks" +import { createAtomHooks } from "@follow/utils" +import type { SetStateAction, WritableAtom } from "jotai" +import { atom as jotaiAtom, useAtomValue } from "jotai" +import { atomWithStorage, selectAtom } from "jotai/utils" +import { useMemo } from "react" +import { shallow } from "zustand/shallow" + +import { JotaiPersistSyncStorage } from "@/src/lib/jotai" + +const getStorageNS = (settingKey: string) => `follow-rn-${settingKey}` +type Nullable = T | null | undefined + +export const createSettingAtom = ( + settingKey: string, + createDefaultSettings: () => T, +) => { + const atom = atomWithStorage( + getStorageNS(settingKey), + createDefaultSettings(), + JotaiPersistSyncStorage, + { + getOnInit: true, + }, + ) as WritableAtom], void> + + const [, , useSettingValue, , getSettings, setSettings] = createAtomHooks(atom) + + const initializeDefaultSettings = () => { + const currentSettings = getSettings() + const defaultSettings = createDefaultSettings() + if (typeof currentSettings !== "object") setSettings(defaultSettings) + const newSettings = { ...defaultSettings, ...currentSettings } + setSettings(newSettings) + } + + const selectAtomCacheMap = {} as Record, any> + + const noopAtom = jotaiAtom(null) + + const useMaybeSettingKey = >(key: Nullable) => { + // @ts-expect-error + let selectedAtom: Record[T] | null = null + if (key) { + selectedAtom = selectAtomCacheMap[key] + if (!selectedAtom) { + selectedAtom = selectAtom(atom, (s) => s[key]) + selectAtomCacheMap[key] = selectedAtom + } + } else { + selectedAtom = noopAtom + } + + return useAtomValue(selectedAtom) as ReturnType[T] + } + + const useSettingKey = >(key: T) => { + return useMaybeSettingKey(key) as ReturnType[T] + } + + function useSettingKeys< + T extends keyof ReturnType, + K1 extends T, + K2 extends T, + K3 extends T, + K4 extends T, + K5 extends T, + K6 extends T, + K7 extends T, + K8 extends T, + K9 extends T, + K10 extends T, + >(keys: [K1, K2?, K3?, K4?, K5?, K6?, K7?, K8?, K9?, K10?]) { + return [ + useMaybeSettingKey(keys[0]), + useMaybeSettingKey(keys[1]), + useMaybeSettingKey(keys[2]), + useMaybeSettingKey(keys[3]), + useMaybeSettingKey(keys[4]), + useMaybeSettingKey(keys[5]), + useMaybeSettingKey(keys[6]), + useMaybeSettingKey(keys[7]), + useMaybeSettingKey(keys[8]), + useMaybeSettingKey(keys[9]), + ] as [ + ReturnType[K1], + ReturnType[K2], + ReturnType[K3], + ReturnType[K4], + ReturnType[K5], + ReturnType[K6], + ReturnType[K7], + ReturnType[K8], + ReturnType[K9], + ReturnType[K10], + ] + } + + const useSettingSelector = < + T extends keyof ReturnType, + S extends ReturnType, + R = S[T], + >( + selector: (s: S) => R, + ): R => { + const stableSelector = useRefValue(selector) + + return useAtomValue( + // @ts-expect-error + useMemo(() => selectAtom(atom, stableSelector.current, shallow), [stableSelector]), + ) + } + + const setSetting = >( + key: K, + value: ReturnType[K], + ) => { + const updated = Date.now() + setSettings({ + ...getSettings(), + [key]: value, + + updated, + }) + } + + const clearSettings = () => { + setSettings(createDefaultSettings()) + } + + Object.defineProperty(useSettingValue, "select", { + value: useSettingSelector, + }) + + return { + useSettingKey, + useSettingSelector, + setSetting, + clearSettings, + initializeDefaultSettings, + + useSettingValue, + useSettingKeys, + getSettings, + + settingAtom: atom, + } as { + useSettingKey: typeof useSettingKey + useSettingSelector: typeof useSettingSelector + setSetting: typeof setSetting + clearSettings: typeof clearSettings + initializeDefaultSettings: typeof initializeDefaultSettings + useSettingValue: typeof useSettingValue & { + select: T>>(key: T) => Awaited + } + useSettingKeys: typeof useSettingKeys + getSettings: typeof getSettings + settingAtom: typeof atom + } +} diff --git a/apps/mobile/src/components/common/BlurEffect.tsx b/apps/mobile/src/components/common/BlurEffect.tsx new file mode 100644 index 0000000000..aff6b96efe --- /dev/null +++ b/apps/mobile/src/components/common/BlurEffect.tsx @@ -0,0 +1,34 @@ +import { StyleSheet } from "react-native" +import { useColor } from "react-native-uikit-colors" + +import { ThemedBlurView } from "@/src/components/common/ThemedBlurView" + +const node = ( + +) +export const BlurEffect = () => { + return node +} + +const InternalBlurEffectWithBottomBorder = () => { + const border = useColor("opaqueSeparator") + return ( + + ) +} + +export const BlurEffectWithBottomBorder = () => diff --git a/apps/mobile/src/components/common/FollowWebView.tsx b/apps/mobile/src/components/common/FollowWebView.tsx index 474f2fba20..2bfbd02e6c 100644 --- a/apps/mobile/src/components/common/FollowWebView.tsx +++ b/apps/mobile/src/components/common/FollowWebView.tsx @@ -1,14 +1,13 @@ import { callWebviewExpose } from "@follow/shared" -import { parseSafeUrl, transformVideoUrl } from "@follow/utils" import * as Linking from "expo-linking" import type { RefObject } from "react" import { useCallback, useEffect, useState } from "react" import { Platform } from "react-native" -import type { WebViewNavigation, WebViewProps } from "react-native-webview" +import type { WebViewProps } from "react-native-webview" import { WebView } from "react-native-webview" +import { useWebViewNavigation } from "@/src/hooks/useWebViewNavigation" import { signOut } from "@/src/lib/auth" -import { useOpenLink } from "@/src/lib/hooks/use-open-link" const presetUri = Platform.select({ ios: "rn-web/index.html", @@ -16,8 +15,6 @@ const presetUri = Platform.select({ default: "https://app.follow.is", }) -const allowHosts = new Set(["app.follow.is"]) - interface FollowWebViewProps extends WebViewProps { customUrl?: string } @@ -80,32 +77,6 @@ export const FollowWebView = ({ ) } -const useWebViewNavigation = ({ webViewRef }: { webViewRef: RefObject }) => { - const openLink = useOpenLink() - - const onNavigationStateChange = useCallback( - (newNavState: WebViewNavigation) => { - const { url: urlStr } = newNavState - const url = parseSafeUrl(urlStr) - if (!url) return - if (url.protocol === "file:") return - if (allowHosts.has(url.host)) return - - webViewRef.current?.stopLoading() - - const formattedUrl = transformVideoUrl({ url: urlStr }) - if (formattedUrl) { - openLink(formattedUrl) - return - } - openLink(urlStr) - }, - [openLink, webViewRef], - ) - - return { onNavigationStateChange } -} - // We only need to handle deep link at the first time the app is opened let lastInitialUrl: string | null = null const useDeepLink = ({ diff --git a/apps/mobile/src/components/common/HeaderBlur.tsx b/apps/mobile/src/components/common/HeaderBlur.tsx deleted file mode 100644 index 06f8f01592..0000000000 --- a/apps/mobile/src/components/common/HeaderBlur.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { StyleSheet } from "react-native" - -import { ThemedBlurView } from "@/src/components/common/ThemedBlurView" - -const node = ( - -) -export const BlurEffect = () => { - return node -} diff --git a/apps/mobile/src/components/common/SafeNavigationScrollView.tsx b/apps/mobile/src/components/common/SafeNavigationScrollView.tsx index 089e8f3d86..ce3f8643a5 100644 --- a/apps/mobile/src/components/common/SafeNavigationScrollView.tsx +++ b/apps/mobile/src/components/common/SafeNavigationScrollView.tsx @@ -1,20 +1,119 @@ import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs" import { useHeaderHeight } from "@react-navigation/elements" -import type { FC } from "react" +import { router, Stack, useNavigation } from "expo-router" +import type { FC, PropsWithChildren } from "react" +import { createContext, useContext, useEffect, useMemo, useState } from "react" import type { ScrollViewProps } from "react-native" -import { ScrollView, View } from "react-native" +import { + Animated as RNAnimated, + StyleSheet, + TouchableOpacity, + useAnimatedValue, + View, +} from "react-native" +import type { ReanimatedScrollEvent } from "react-native-reanimated/lib/typescript/hook/commonTypes" import { useSafeAreaInsets } from "react-native-safe-area-context" +import { useColor } from "react-native-uikit-colors" -export const SafeNavigationScrollView: FC = ({ children, ...props }) => { - const headerHeight = useHeaderHeight() - const tabBarHeight = useBottomTabBarHeight() +import { MingcuteLeftLineIcon } from "@/src/icons/mingcute_left_line" + +import { AnimatedScrollView } from "./AnimatedComponents" +import { ThemedBlurView } from "./ThemedBlurView" + +type SafeNavigationScrollViewProps = Omit & { + withHeaderBlur?: boolean + onScroll?: (e: ReanimatedScrollEvent) => void + + // For scroll view content adjustment behavior + withTopInset?: boolean + withBottomInset?: boolean +} & PropsWithChildren +const NavigationContext = createContext<{ + scrollY: RNAnimated.Value +} | null>(null) + +export const SafeNavigationScrollView: FC = ({ + children, + + withHeaderBlur = true, + onScroll, + + withBottomInset = false, + withTopInset = false, + + ...props +}) => { const insets = useSafeAreaInsets() + const tabBarHeight = useBottomTabBarHeight() + const headerHeight = useHeaderHeight() + + const scrollY = useAnimatedValue(0) return ( - - - {children} - - + ({ scrollY }), [scrollY])}> + {withHeaderBlur && } + + + {children} + + + + ) +} + +export interface NavigationBlurEffectHeaderProps { + title?: string +} +export const NavigationBlurEffectHeader = (props: NavigationBlurEffectHeaderProps) => { + const label = useColor("label") + + const canBack = useNavigation().canGoBack() + + const { scrollY } = useContext(NavigationContext)! + + const border = useColor("opaqueSeparator") + + const [opacity, setOpacity] = useState(0) + + useEffect(() => { + const id = scrollY.addListener(({ value }) => { + setOpacity(Math.min(1, Math.max(0, Math.min(1, value / 10)))) + }) + + return () => { + scrollY.removeListener(id) + } + }, [scrollY]) + + return ( + ( + + ), + headerTransparent: true, + + headerLeft: canBack + ? () => ( + router.back()}> + + + ) + : undefined, + title: props.title, + }} + /> ) } diff --git a/apps/mobile/src/components/common/ThemedBlurView.tsx b/apps/mobile/src/components/common/ThemedBlurView.tsx index 806c3bdf83..f917ab97bb 100644 --- a/apps/mobile/src/components/common/ThemedBlurView.tsx +++ b/apps/mobile/src/components/common/ThemedBlurView.tsx @@ -5,10 +5,12 @@ import { forwardRef } from "react" export const ThemedBlurView = forwardRef(({ tint, ...rest }, ref) => { const { colorScheme } = useColorScheme() + return ( ) diff --git a/apps/mobile/src/components/ui/dropdown/DropdownMenu.tsx b/apps/mobile/src/components/ui/dropdown/DropdownMenu.tsx new file mode 100644 index 0000000000..cf61d2d8ce --- /dev/null +++ b/apps/mobile/src/components/ui/dropdown/DropdownMenu.tsx @@ -0,0 +1,53 @@ +import type { NativeSyntheticEvent } from "react-native" +import type { ContextMenuOnPressNativeEvent } from "react-native-context-menu-view" +import ContextMenu from "react-native-context-menu-view" + +interface DropdownMenuAction { + title: string + actions?: DropdownMenuAction[] + selected?: boolean +} + +interface DropdownMenuSelector { + label: string + value: T +} + +export function DropdownMenu({ + options, + currentValue, + handleChangeValue, + handlePress, + children, +}: { + options: DropdownMenuSelector[] | DropdownMenuAction[] + currentValue?: T + handleChangeValue?: (value: T) => void + handlePress?: (e: NativeSyntheticEvent) => void + children: React.ReactNode +}) { + const isActionMenu = options.every((option) => "title" in option) + return ( + ({ + title: option.label, + selected: option.value === currentValue, + })) + } + onPress={(e) => { + if (!isActionMenu) { + const { index } = e.nativeEvent + handleChangeValue?.(options[index]!.value) + } + handlePress?.(e) + }} + > + {children} + + ) +} diff --git a/apps/mobile/src/components/ui/form/PickerIos.tsx b/apps/mobile/src/components/ui/form/PickerIos.tsx index 9456ce60f4..bf8cbe5eab 100644 --- a/apps/mobile/src/components/ui/form/PickerIos.tsx +++ b/apps/mobile/src/components/ui/form/PickerIos.tsx @@ -11,7 +11,7 @@ import { useEventCallback } from "usehooks-ts" import { MingcuteDownLineIcon } from "@/src/icons/mingcute_down_line" import { useColor } from "@/src/theme/colors" -import { BlurEffect } from "../../common/HeaderBlur" +import { BlurEffect } from "../../common/BlurEffect" interface PickerIosProps { options: { label: string; value: T }[] diff --git a/apps/mobile/src/components/ui/form/Select.tsx b/apps/mobile/src/components/ui/form/Select.tsx index ccd1d8fa4b..f60abab9de 100644 --- a/apps/mobile/src/components/ui/form/Select.tsx +++ b/apps/mobile/src/components/ui/form/Select.tsx @@ -2,12 +2,12 @@ import { cn } from "@follow/utils" import { useEffect, useMemo, useState } from "react" import type { StyleProp, ViewStyle } from "react-native" import { Text, View } from "react-native" -import ContextMenu from "react-native-context-menu-view" import { useEventCallback } from "usehooks-ts" import { MingcuteDownLineIcon } from "@/src/icons/mingcute_down_line" import { accentColor } from "@/src/theme/colors" +import { DropdownMenu } from "../dropdown/DropdownMenu" import { FormLabel } from "./Label" interface SelectProps { @@ -52,36 +52,43 @@ export function Select({ onValueChange(currentValue) }, []) - return ( - - {!!label && } - - {/* Trigger */} - ({ - title: option.label, - selected: option.value === currentValue, - }))} - onPress={(e) => { - const { index } = e.nativeEvent - handleChangeValue(options[index]!.value) - }} + const Trigger = ( + + options={options.map((option) => ({ + label: option.label, + value: option.value, + }))} + currentValue={currentValue} + handleChangeValue={handleChangeValue} + > + - - {valueToLabelMap.get(currentValue)} - - - + + {valueToLabelMap.get(currentValue)} + + + - + + + ) + + if (!label) { + return Trigger + } + + return ( + + + + + {Trigger} ) } diff --git a/apps/mobile/src/components/ui/form/Switch.tsx b/apps/mobile/src/components/ui/form/Switch.tsx index 7d179faefb..20fa0a280b 100644 --- a/apps/mobile/src/components/ui/form/Switch.tsx +++ b/apps/mobile/src/components/ui/form/Switch.tsx @@ -1,9 +1,9 @@ import { forwardRef } from "react" -import type { StyleProp, SwitchProps, ViewStyle } from "react-native" -import { Switch, Text, View } from "react-native" - -import { accentColor } from "@/src/theme/colors" +import type { StyleProp, ViewStyle } from "react-native" +import { Text, View } from "react-native" +import type { SwitchProps, SwitchRef } from "../switch/Switch" +import { Switch } from "../switch/Switch" import { FormLabel } from "./Label" interface Props { @@ -12,19 +12,26 @@ interface Props { label?: string description?: string + + size?: "sm" | "default" } -export const FormSwitch = forwardRef( - ({ wrapperClassName, wrapperStyle, label, description, ...rest }, ref) => { +export const FormSwitch = forwardRef( + ({ wrapperClassName, wrapperStyle, label, description, size = "default", ...rest }, ref) => { + const Trigger = + + if (!label) { + return Trigger + } return ( - {!!label && } + {!!description && ( {description} )} - + {Trigger} ) }, diff --git a/apps/mobile/src/components/ui/grouped/GroupedList.tsx b/apps/mobile/src/components/ui/grouped/GroupedList.tsx index 5f566ce9fd..69c8a4bad1 100644 --- a/apps/mobile/src/components/ui/grouped/GroupedList.tsx +++ b/apps/mobile/src/components/ui/grouped/GroupedList.tsx @@ -8,17 +8,33 @@ import { Pressable, StyleSheet, Text, View } from "react-native" import { RightCuteReIcon } from "@/src/icons/right_cute_re" import { useColor } from "@/src/theme/colors" -export const GroupedInsetListCard: FC = ({ children }) => { +export const GroupedInsetListCard: FC = ({ + children, + className, + ...props +}) => { return ( - + {React.Children.map(children, (child, index) => { const isLast = index === React.Children.count(children) - 1 + + const isNavigationLink = + React.isValidElement(child) && + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + (child.type as Function).name === GroupedInsetListNavigationLink.name + return ( {child} {!isLast && ( )} @@ -41,9 +57,15 @@ export const GroupedInsetListSectionHeader: FC<{ ) } -export const GroupedInsetListItem: FC = ({ children, ...props }) => { +export const GroupedInsetListBaseCell: FC = ({ + children, + ...props +}) => { return ( - + {children} ) @@ -59,8 +81,8 @@ export const GroupedInsetListNavigationLink: FC<{ return ( {({ pressed }) => ( - - + + {icon} {label} @@ -69,8 +91,42 @@ export const GroupedInsetListNavigationLink: FC<{ - + )} ) } + +export const GroupedInsetListNavigationLinkIcon: FC< + { + backgroundColor: string + } & PropsWithChildren +> = ({ backgroundColor, children }) => { + return ( + + {children} + + ) +} + +export const GroupedInsetListCell: FC<{ + label: string + description?: string + children: React.ReactNode +}> = ({ label, description, children }) => { + return ( + + + {label} + {!!description && {description}} + + + {children} + + ) +} diff --git a/apps/mobile/src/components/ui/switch/Switch.tsx b/apps/mobile/src/components/ui/switch/Switch.tsx new file mode 100644 index 0000000000..fe22f83732 --- /dev/null +++ b/apps/mobile/src/components/ui/switch/Switch.tsx @@ -0,0 +1,159 @@ +import { forwardRef, useEffect, useImperativeHandle } from "react" +import type { SwitchChangeEvent } from "react-native" +import { Pressable, StyleSheet, View } from "react-native" +import Animated, { + interpolate, + interpolateColor, + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, +} from "react-native-reanimated" + +import { accentColor, useColor } from "@/src/theme/colors" + +export interface SwitchProps { + onChange?: ((event: SwitchChangeEvent) => Promise | void) | null | undefined + + /** + * Invoked with the new value when the value changes. + */ + onValueChange?: ((value: boolean) => Promise | void) | null | undefined + + /** + * The value of the switch. If true the switch will be turned on. + * Default value is false. + */ + value?: boolean | undefined + + size?: "sm" | "default" +} + +export type SwitchRef = { + value: boolean +} +export const Switch = forwardRef( + ({ value, onValueChange, onChange, size = "default" }, ref) => { + const progress = useSharedValue(value ? 1 : 0) + const scale = useSharedValue(1) + const translateX = useSharedValue(0) + + const onTouchStart = () => { + scale.value = withSpring(1.1) + if (value) { + translateX.value = withSpring(size === "sm" ? -4 : -7) + } + } + + const onTouchEnd = () => { + scale.value = withSpring(1) + translateX.value = withSpring(0) + } + + useImperativeHandle(ref, () => ({ + value: !!value, + })) + + const activeBgColor = accentColor + const inactiveBgColor = useColor("secondarySystemFill") + + const toggleStyle = useAnimatedStyle(() => { + const backgroundColor = interpolateColor( + progress.value, + [0, 1], + [inactiveBgColor, activeBgColor], + ) + + return { + backgroundColor, + } + }) + + const circleStyle = useAnimatedStyle(() => { + const marginLeft = interpolate(progress.value, [0, 1], size === "sm" ? [2, 20] : [2.3, 22]) + + const width = interpolate(scale.value, [1, 1.1], size === "sm" ? [18, 21] : [27.8, 35]) + + return { + marginLeft, + width, + transform: [{ translateX: translateX.value }, { translateY: -0.4 }, { scale: scale.value }], + } + }) + + useEffect(() => { + // Update progress when value changes + if (value && progress.value === 0) { + progress.value = withTiming(1) + } else if (!value && progress.value === 1) { + progress.value = withTiming(0) + } + }, [progress, value]) + + return ( + + { + onValueChange?.(!value) + onChange?.({ target: { value: !value } as any } as SwitchChangeEvent) + }} + > + + + + + + ) + }, +) + +const styles = StyleSheet.create({ + container: { display: "flex", justifyContent: "space-between" }, + toggleContainer: { + width: 52, + height: 32.7, + borderRadius: 4000, + justifyContent: "center", + }, + toggleContainerSm: { + width: 40, + height: 24, + borderRadius: 4000, + justifyContent: "center", + }, + toggleWheelStyle: { + height: 28.5, + backgroundColor: "#ffffff", + borderRadius: 200, + shadowColor: "#515151", + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0.2, + shadowRadius: 2.5, + elevation: 1.5, + }, + toggleWheelStyleSm: { + height: 20, + backgroundColor: "#ffffff", + borderRadius: 200, + shadowColor: "#515151", + shadowOffset: { + width: 0, + height: 0, + }, + shadowOpacity: 0.2, + shadowRadius: 2.5, + elevation: 1.5, + }, +}) diff --git a/apps/mobile/src/components/ui/typography/Markdown.tsx b/apps/mobile/src/components/ui/typography/Markdown.tsx new file mode 100644 index 0000000000..2baf6552cb --- /dev/null +++ b/apps/mobile/src/components/ui/typography/Markdown.tsx @@ -0,0 +1,41 @@ +import type { FC } from "react" +import { useRef } from "react" +import { Linking } from "react-native" +import type { WebView } from "react-native-webview" + +import MarkdownWeb from "./MarkdownWeb" + +export const Markdown: FC<{ + value: string + style?: React.CSSProperties + className?: string + + webViewProps?: import("expo/dom").DOMProps +}> = ({ value, style, className, webViewProps }) => { + const ref = useRef(null) + + return ( + { + const { type, url } = JSON.parse(event.nativeEvent.data) + if (type === "openLinkInModal") { + Linking.openURL(url) + } + }, + injectedJavaScriptBeforeContentLoaded: `window.openLinkInModal = (url) => { + window.ReactNativeWebView.postMessage(JSON.stringify({ + type: "openLinkInModal", + url, + })) + }`, + }} + /> + ) +} diff --git a/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx b/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx index 37158ce36a..e50ea79880 100644 --- a/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx +++ b/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx @@ -8,29 +8,58 @@ import { useDarkMode } from "usehooks-ts" import { useCSSInjection } from "@/src/theme/web" -const MarkdownWeb: WebComponent<{ value: string; style?: React.CSSProperties }> = ({ - value, - style, -}) => { +declare const window: { + openLinkInModal: (url: string) => void +} +/** + * @internal + */ +const MarkdownWeb: WebComponent<{ + value: string + style?: React.CSSProperties + className?: string +}> = ({ value, style, className }) => { useCSSInjection() const { isDarkMode } = useDarkMode() return (