diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index a7be96d38f..b9d5fd6e2f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -50,6 +50,12 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ favicon: iconPath, }, plugins: [ + [ + "expo-document-picker", + { + iCloudContainerEnvironment: "Production", + }, + ], "expo-localization", [ "expo-router", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index d53cfa95b7..f824141621 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -45,6 +45,7 @@ "expo-clipboard": "~7.0.0", "expo-constants": "~17.0.4", "expo-dev-client": "^5.0.9", + "expo-document-picker": "~13.0.2", "expo-file-system": "~18.0.6", "expo-font": "~13.0.1", "expo-haptics": "~14.0.0", @@ -88,6 +89,7 @@ "react-native-uikit-colors": "0.1.1", "react-native-web": "~0.19.13", "react-native-webview": "13.12.5", + "shiki": "1.24.1", "swiftui-react-native": "6.3.3", "tailwindcss": "3.4.16", "usehooks-ts": "3.1.0", diff --git a/apps/mobile/src/atoms/settings/data.ts b/apps/mobile/src/atoms/settings/data.ts new file mode 100644 index 0000000000..b601933b2a --- /dev/null +++ b/apps/mobile/src/atoms/settings/data.ts @@ -0,0 +1,18 @@ +import type { DataSettings } from "@/src/interfaces/settings/data" + +import { createSettingAtom } from "./internal/helper" + +export const createDefaultSettings = (): DataSettings => ({ + sendAnonymousData: true, +}) + +export const { + useSettingKey: useDataSettingKey, + useSettingSelector: useDataSettingSelector, + useSettingKeys: useDataSettingKeys, + setSetting: setDataSetting, + clearSettings: clearDataSettings, + initializeDefaultSettings: initializeDefaultDataSettings, + getSettings: getDataSettings, + useSettingValue: useDataSettingValue, +} = createSettingAtom("data", createDefaultSettings) diff --git a/apps/mobile/src/atoms/settings/ui.ts b/apps/mobile/src/atoms/settings/ui.ts new file mode 100644 index 0000000000..7d3b4bd11d --- /dev/null +++ b/apps/mobile/src/atoms/settings/ui.ts @@ -0,0 +1,36 @@ +import type { UISettings } from "@/src/interfaces/settings/ui" + +import { createSettingAtom } from "./internal/helper" + +export const createDefaultSettings = (): UISettings => ({ + // Subscription + + hideExtraBadge: false, + + subscriptionShowUnreadCount: true, + thumbnailRatio: "square", + + // Content + readerRenderInlineStyle: false, + codeHighlightThemeLight: "github-light", + codeHighlightThemeDark: "github-dark", + guessCodeLanguage: true, + hideRecentReader: false, + customCSS: "", + + // View + + pictureViewFilterNoImage: false, +}) + +export const { + useSettingKey: useUISettingKey, + useSettingSelector: useUISettingSelector, + useSettingKeys: useUISettingKeys, + setSetting: setUISetting, + clearSettings: clearUISettings, + initializeDefaultSettings: initializeDefaultUISettings, + getSettings: getUISettings, + useSettingValue: useUISettingValue, + settingAtom: __uiSettingAtom, +} = createSettingAtom("ui", createDefaultSettings) diff --git a/apps/mobile/src/components/ui/dropdown/DropdownMenu.tsx b/apps/mobile/src/components/ui/dropdown/DropdownMenu.tsx index cf61d2d8ce..97ae117796 100644 --- a/apps/mobile/src/components/ui/dropdown/DropdownMenu.tsx +++ b/apps/mobile/src/components/ui/dropdown/DropdownMenu.tsx @@ -37,6 +37,7 @@ export function DropdownMenu({ : options.map((option) => ({ title: option.label, selected: option.value === currentValue, + disabled: option.value === currentValue, })) } onPress={(e) => { diff --git a/apps/mobile/src/components/ui/grouped/GroupedList.tsx b/apps/mobile/src/components/ui/grouped/GroupedList.tsx index 69c8a4bad1..68b85e6d44 100644 --- a/apps/mobile/src/components/ui/grouped/GroupedList.tsx +++ b/apps/mobile/src/components/ui/grouped/GroupedList.tsx @@ -75,20 +75,23 @@ export const GroupedInsetListNavigationLink: FC<{ label: string icon?: React.ReactNode onPress: () => void -}> = ({ label, icon, onPress }) => { - const tertiaryLabelColor = useColor("tertiaryLabel") + disabled?: boolean +}> = ({ label, icon, onPress, disabled }) => { + const rightIconColor = useColor("tertiaryLabel") return ( - + {({ pressed }) => ( - - + + {icon} - {label} + {label} - - + + @@ -117,7 +120,7 @@ export const GroupedInsetListNavigationLinkIcon: FC< export const GroupedInsetListCell: FC<{ label: string description?: string - children: React.ReactNode + children?: React.ReactNode }> = ({ label, description, children }) => { return ( @@ -126,7 +129,34 @@ export const GroupedInsetListCell: FC<{ {!!description && {description}} - {children} + {children} ) } + +export const GroupedInsetListActionCell: FC<{ + label: string + description?: string + onPress: () => void + disabled?: boolean +}> = ({ label, description, onPress, disabled }) => { + const rightIconColor = useColor("tertiaryLabel") + return ( + + {({ pressed }) => ( + + + {label} + {!!description && {description}} + + + + + + + )} + + ) +} diff --git a/apps/mobile/src/icons/exit_cute_fi.tsx b/apps/mobile/src/icons/exit_cute_fi.tsx new file mode 100644 index 0000000000..70a895c9dd --- /dev/null +++ b/apps/mobile/src/icons/exit_cute_fi.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface ExitCuteFiIconProps { + width?: number + height?: number + color?: string +} + +export const ExitCuteFiIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: ExitCuteFiIconProps) => { + return ( + + + + + ) +} diff --git a/apps/mobile/src/interfaces/settings/data.ts b/apps/mobile/src/interfaces/settings/data.ts new file mode 100644 index 0000000000..795c834461 --- /dev/null +++ b/apps/mobile/src/interfaces/settings/data.ts @@ -0,0 +1,3 @@ +export interface DataSettings { + sendAnonymousData: boolean +} diff --git a/apps/mobile/src/interfaces/settings/ui.ts b/apps/mobile/src/interfaces/settings/ui.ts new file mode 100644 index 0000000000..180226be5b --- /dev/null +++ b/apps/mobile/src/interfaces/settings/ui.ts @@ -0,0 +1,17 @@ +export interface UISettings { + subscriptionShowUnreadCount: boolean + hideExtraBadge: boolean + thumbnailRatio: "square" | "original" + + // Content + readerRenderInlineStyle: boolean + codeHighlightThemeLight: string + codeHighlightThemeDark: string + guessCodeLanguage: boolean + hideRecentReader: boolean + customCSS: string + + // view + + pictureViewFilterNoImage: boolean +} diff --git a/apps/mobile/src/lib/api-fetch.ts b/apps/mobile/src/lib/api-fetch.ts index 272a6cad3f..13b4e2de9f 100644 --- a/apps/mobile/src/lib/api-fetch.ts +++ b/apps/mobile/src/lib/api-fetch.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ import type { AppType } from "@follow/shared" import { router } from "expo-router" -import { ofetch } from "ofetch" +import { FetchError, ofetch } from "ofetch" import { getCookie } from "./auth" import { getApiUrl } from "./env" @@ -12,11 +12,16 @@ export const apiFetch = ofetch.create({ retry: false, baseURL: getApiUrl(), - onRequest: async ({ options, request }) => { + onRequest: async (ctx) => { + const { options, request } = ctx if (__DEV__) { // Logger console.log(`---> ${options.method} ${request as string}`) } + + // add cookie + options.headers = options.headers || new Headers() + options.headers.set("cookie", getCookie()) }, onRequestError: ({ error, request, options }) => { if (__DEV__) { @@ -54,3 +59,19 @@ export const apiClient = hc(getApiUrl(), { } }, }) + +export const getBizFetchErrorMessage = (error: unknown) => { + if (error instanceof FetchError && error.response) { + try { + const data = JSON.parse(error.response._data) + + if (data.message && data.code) { + // TODO i18n handle by code + return data.message + } + } catch { + return "" + } + } + return "" +} diff --git a/apps/mobile/src/modules/feed-drawer/collection-panel.tsx b/apps/mobile/src/modules/feed-drawer/collection-panel.tsx index 9610177583..6437c64c7b 100644 --- a/apps/mobile/src/modules/feed-drawer/collection-panel.tsx +++ b/apps/mobile/src/modules/feed-drawer/collection-panel.tsx @@ -1,15 +1,9 @@ import { cn } from "@follow/utils" -import { - Image, - ScrollView, - StyleSheet, - TouchableOpacity, - useWindowDimensions, - View, -} from "react-native" +import { Image, ScrollView, StyleSheet, TouchableOpacity, View } from "react-native" import { useSafeAreaInsets } from "react-native-safe-area-context" import { FallbackIcon } from "@/src/components/ui/icon/fallback-icon" +import { Logo } from "@/src/components/ui/logo" import type { ViewDefinition } from "@/src/constants/views" import { views } from "@/src/constants/views" import { useList } from "@/src/store/list/hooks" @@ -18,23 +12,26 @@ import { useAllListSubscription } from "@/src/store/subscription/hooks" import { selectCollection, useSelectedCollection } from "./atoms" export const CollectionPanel = () => { - const winDim = useWindowDimensions() const lists = useAllListSubscription() const insets = useSafeAreaInsets() return ( + + + + {views.map((viewDef) => ( ))} - + {lists.map((listId) => ( ))} @@ -56,7 +53,7 @@ const ViewButton = ({ viewDef }: { viewDef: ViewDefinition }) => { return ( @@ -65,8 +62,9 @@ const ViewButton = ({ viewDef }: { viewDef: ViewDefinition }) => { viewId: viewDef.view, }) } + style={{ backgroundColor: viewDef.activeColor }} > - + ) } @@ -80,7 +78,7 @@ const ListButton = ({ listId }: { listId: string }) => { return ( @@ -90,13 +88,11 @@ const ListButton = ({ listId }: { listId: string }) => { }) } > - - {list.image ? ( - - ) : ( - - )} - + {list.image ? ( + + ) : ( + + )} ) } diff --git a/apps/mobile/src/modules/feed-drawer/drawer.tsx b/apps/mobile/src/modules/feed-drawer/drawer.tsx index 7ecb4ef229..da1ef9fad0 100644 --- a/apps/mobile/src/modules/feed-drawer/drawer.tsx +++ b/apps/mobile/src/modules/feed-drawer/drawer.tsx @@ -46,7 +46,7 @@ export const FeedDrawer = ({ children }: PropsWithChildren) => { return ( {submitMutation.isPending ? ( diff --git a/apps/mobile/src/modules/settings/SettingsList.tsx b/apps/mobile/src/modules/settings/SettingsList.tsx index c18426a769..e43722972f 100644 --- a/apps/mobile/src/modules/settings/SettingsList.tsx +++ b/apps/mobile/src/modules/settings/SettingsList.tsx @@ -1,9 +1,10 @@ import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs" import { useIsFocused } from "@react-navigation/native" +import * as FileSystem from "expo-file-system" import type { FC, RefObject } from "react" -import { useContext, useEffect } from "react" +import { Fragment, useContext, useEffect } from "react" import type { ScrollView } from "react-native" -import { View } from "react-native" +import { Alert, View } from "react-native" import { useSafeAreaInsets } from "react-native-safe-area-context" import { @@ -12,9 +13,11 @@ import { GroupedInsetListNavigationLinkIcon, } from "@/src/components/ui/grouped/GroupedList" import { SetBottomTabBarVisibleContext } from "@/src/contexts/BottomTabBarVisibleContext" +import { getDbPath } from "@/src/database" import { BellRingingCuteFiIcon } from "@/src/icons/bell_ringing_cute_fi" import { CertificateCuteFiIcon } from "@/src/icons/certificate_cute_fi" import { DatabaseIcon } from "@/src/icons/database" +import { ExitCuteFiIcon } from "@/src/icons/exit_cute_fi" import { Magic2CuteFiIcon } from "@/src/icons/magic_2_cute_fi" import { PaletteCuteFiIcon } from "@/src/icons/palette_cute_fi" import { RadaCuteFiIcon } from "@/src/icons/rada_cute_fi" @@ -23,6 +26,7 @@ import { Settings7CuteFiIcon } from "@/src/icons/settings_7_cute_fi" import { StarCuteFiIcon } from "@/src/icons/star_cute_fi" import { TrophyCuteFiIcon } from "@/src/icons/trophy_cute_fi" import { User3CuteFiIcon } from "@/src/icons/user_3_cute_fi" +import { signOut } from "@/src/lib/auth" import { useSettingsNavigation } from "./hooks" @@ -34,6 +38,8 @@ interface GroupNavigationLink { scrollRef: RefObject, ) => void iconBackgroundColor: string + + todo?: boolean } const UserGroupNavigationLinks: GroupNavigationLink[] = [ { @@ -73,6 +79,7 @@ const SettingGroupNavigationLinks: GroupNavigationLink[] = [ navigation.navigate("Notifications") }, iconBackgroundColor: "#FBBF24", + todo: true, }, { label: "Appearance", @@ -139,6 +146,68 @@ const PrivacyGroupNavigationLinks: GroupNavigationLink[] = [ }, ] +const ActionGroupNavigationLinks: GroupNavigationLink[] = [ + { + label: "Sign out", + icon: ExitCuteFiIcon, + onPress: () => { + Alert.alert("Sign out", "Are you sure you want to sign out?", [ + { + text: "Cancel", + style: "cancel", + }, + { + text: "Sign out", + style: "destructive", + onPress: async () => { + // sign out + await signOut() + const dbPath = getDbPath() + await FileSystem.deleteAsync(dbPath) + await expo.reloadAppAsync("User sign out") + }, + }, + ]) + }, + iconBackgroundColor: "#F87181", + }, +] + +const NavigationLinkGroup: FC<{ + links: GroupNavigationLink[] + navigation: ReturnType + scrollRef: RefObject +}> = ({ links, navigation, scrollRef }) => ( + + {links.map((link) => ( + + + + } + onPress={() => { + if (link.todo) { + return + } + link.onPress(navigation, scrollRef) + }} + /> + ))} + +) + +const navigationGroups = [ + UserGroupNavigationLinks, + DataGroupNavigationLinks, + SettingGroupNavigationLinks, + PrivacyGroupNavigationLinks, + ActionGroupNavigationLinks, +] as const + export const SettingsList: FC<{ scrollRef: RefObject }> = ({ scrollRef }) => { const navigation = useSettingsNavigation() @@ -157,68 +226,17 @@ export const SettingsList: FC<{ scrollRef: RefObject }> = ({ scrollR className="bg-system-grouped-background flex-1 py-4" style={{ paddingBottom: insets.bottom + tabBarHeight }} > - - {UserGroupNavigationLinks.map((link) => ( - - - - } - onPress={() => link.onPress(navigation, scrollRef)} - /> - ))} - - - - - {DataGroupNavigationLinks.map((link) => ( - - - - } - onPress={() => link.onPress(navigation, scrollRef)} - /> - ))} - - - - - {SettingGroupNavigationLinks.map((link) => ( - - - - } - onPress={() => link.onPress(navigation, scrollRef)} - /> - ))} - - - - - {PrivacyGroupNavigationLinks.map((link) => ( - - - - } - onPress={() => link.onPress(navigation, scrollRef)} + {navigationGroups.map((group, index) => ( + + - ))} - + {index < navigationGroups.length - 1 && } + + ))} ) } diff --git a/apps/mobile/src/modules/settings/routes/About.tsx b/apps/mobile/src/modules/settings/routes/About.tsx index bfc13c009a..5724317f20 100644 --- a/apps/mobile/src/modules/settings/routes/About.tsx +++ b/apps/mobile/src/modules/settings/routes/About.tsx @@ -1,4 +1,5 @@ import Constants from "expo-constants" +import { Link } from "expo-router" import { Linking, Text, View } from "react-native" import { @@ -12,19 +13,10 @@ import { GroupedInsetListSectionHeader, } from "@/src/components/ui/grouped/GroupedList" import { Logo } from "@/src/components/ui/logo" -import { Markdown } from "@/src/components/ui/typography/Markdown" import { DiscordCuteFiIcon } from "@/src/icons/discord_cute_fi" import { Github2CuteFiIcon } from "@/src/icons/github_2_cute_fi" import { SocialXCuteReIcon } from "@/src/icons/social_x_cute_re" -const about = ` -Follow is in the early stages of development. If you have any feedback or suggestions, please feel free to open an issue on the [GitHub repository](https://github.com/RSSNext/follow). - -The icon library used is copyrighted by https://mgc.mingcute.com/ and cannot be redistributed. - -Copyright © 2025 Follow. All rights reserved. -` - const links = [ { title: "Github", @@ -63,16 +55,28 @@ export const AboutScreen = () => { {appVersion} ({buildId}) - - + + + Follow is in the early stages of development. If you have any feedback or suggestions, + please feel free to open an issue on the{" "} + + GitHub repository + + + + + The icon library used is copyrighted by{" "} + + https://mgc.mingcute.com/ + {" "} + and cannot be redistributed. + + + + Copyright © 2025 Follow. All rights reserved. + - + {links.map((link) => ( diff --git a/apps/mobile/src/modules/settings/routes/Appearance.tsx b/apps/mobile/src/modules/settings/routes/Appearance.tsx index 6b6dfc7484..fda76bd490 100644 --- a/apps/mobile/src/modules/settings/routes/Appearance.tsx +++ b/apps/mobile/src/modules/settings/routes/Appearance.tsx @@ -1,9 +1,129 @@ -import { Text, View } from "react-native" +import { useColorScheme, View } from "react-native" +import { bundledThemesInfo } from "shiki/dist/themes.mjs" + +import { setUISetting, useUISettingKey } from "@/src/atoms/settings/ui" +import { + NavigationBlurEffectHeader, + SafeNavigationScrollView, +} from "@/src/components/common/SafeNavigationScrollView" +import { Select } from "@/src/components/ui/form/Select" +import { + GroupedInsetListCard, + GroupedInsetListCell, + GroupedInsetListSectionHeader, +} from "@/src/components/ui/grouped/GroupedList" +import { Switch } from "@/src/components/ui/switch/Switch" export const AppearanceScreen = () => { + const showUnreadCount = useUISettingKey("subscriptionShowUnreadCount") + const hideExtraBadge = useUISettingKey("hideExtraBadge") + const thumbnailRatio = useUISettingKey("thumbnailRatio") + + const codeThemeLight = useUISettingKey("codeHighlightThemeLight") + const codeThemeDark = useUISettingKey("codeHighlightThemeDark") + + const colorScheme = useColorScheme() + const readerRenderInlineStyle = useUISettingKey("readerRenderInlineStyle") + const hideRecentReader = useUISettingKey("hideRecentReader") + return ( - - Appearance Settings - + + + + + + + { + setUISetting("subscriptionShowUnreadCount", val) + }} + /> + + + + { + setUISetting("hideExtraBadge", val) + }} + /> + + + + ({ + label: theme.displayName, + value: theme.id, + }))} + value={colorScheme === "dark" ? codeThemeDark : codeThemeLight} + onValueChange={(val) => { + setUISetting( + `codeHighlightTheme${colorScheme === "dark" ? "Dark" : "Light"}`, + val, + ) + }} + /> + + + + { + setUISetting("readerRenderInlineStyle", val) + }} + /> + + + + { + setUISetting("hideRecentReader", val) + }} + /> + + + + + ) } diff --git a/apps/mobile/src/modules/settings/routes/Data.tsx b/apps/mobile/src/modules/settings/routes/Data.tsx index 7bbd240de5..feb00aee03 100644 --- a/apps/mobile/src/modules/settings/routes/Data.tsx +++ b/apps/mobile/src/modules/settings/routes/Data.tsx @@ -1,9 +1,199 @@ -import { Text, View } from "react-native" +import * as DocumentPicker from "expo-document-picker" +import * as FileSystem from "expo-file-system" +import * as Sharing from "expo-sharing" +import { Alert, View } from "react-native" + +import { setDataSetting, useDataSettingKey } from "@/src/atoms/settings/data" +import { + NavigationBlurEffectHeader, + SafeNavigationScrollView, +} from "@/src/components/common/SafeNavigationScrollView" +import { + GroupedInsetListActionCell, + GroupedInsetListCard, + GroupedInsetListCell, + GroupedInsetListSectionHeader, +} from "@/src/components/ui/grouped/GroupedList" +import { Switch } from "@/src/components/ui/switch/Switch" +import { getDbPath } from "@/src/database" +import { apiFetch, getBizFetchErrorMessage } from "@/src/lib/api-fetch" +import { toast } from "@/src/lib/toast" + +type FeedResponseList = { + id: string + url: string + title: string | null +}[] + +type FileUpload = { + uri: string + name: string + type: string +} export const DataScreen = () => { + const sendAnonymousData = useDataSettingKey("sendAnonymousData") return ( - - Data Management - + + + + + + + + { + setDataSetting("sendAnonymousData", val) + }} + /> + + + + + {/* Data Sources */} + + + + { + const result = await DocumentPicker.getDocumentAsync({ + type: ["application/octet-stream", "text/x-opml"], + }) + if (result.canceled) { + return + } + + try { + const formData = new FormData() + const file = result.assets[0] + + if (!file) { + toast.error("No file selected") + return + } + + formData.append("file", { + uri: file.uri, + type: file.mimeType || "application/octet-stream", + name: file.name, + } as FileUpload as any) + + const { data } = await apiFetch<{ + data: { + successfulItems: FeedResponseList + conflictItems: FeedResponseList + parsedErrorItems: FeedResponseList + } + }>("/subscriptions/import", { + method: "POST", + body: formData, + headers: { + "Content-Type": "multipart/form-data", + }, + }) + + const { successfulItems, conflictItems, parsedErrorItems } = data + toast.info( + `Import successful, ${successfulItems.length} feeds were imported, ${conflictItems.length} feeds were already subscribed, and ${parsedErrorItems.length} feeds failed to import.`, + ) + } catch (error) { + const bizError = getBizFetchErrorMessage(error) + toast.error(`Import failed${bizError ? `: ${bizError}` : ""}`) + console.error(error) + } + }} + label="Import subscriptions from OPML" + /> + + { + const dbPath = getDbPath() + try { + const destinationUri = `${FileSystem.documentDirectory}follow.db` + await FileSystem.copyAsync({ + from: dbPath, + to: destinationUri, + }) + + await FileSystem.getInfoAsync(destinationUri) + await Sharing.shareAsync(destinationUri, { + UTI: "public.database", + mimeType: "application/x-sqlite3", + dialogTitle: "Export Database", + }) + } catch (error) { + console.error(error) + toast.error("Failed to export database") + } + }} + label="Export local database" + /> + + + + {/* Utils */} + + + + + + { + Alert.alert( + "Rebuild database?", + "This will delete all your offline cached data and rebuild the database, and after that the app will reload.", + [ + { + text: "Cancel", + style: "cancel", + }, + { + text: "Rebuild", + style: "destructive", + onPress: async () => { + const dbPath = getDbPath() + await FileSystem.deleteAsync(dbPath) + await expo.reloadAppAsync("Clear Sqlite Data") + }, + }, + ], + ) + }} + label="Rebuild database" + description="If you are experiencing rendering issues, rebuilding the database may solve them." + /> + + { + Alert.alert("Clear cache?", "This will clear all temporary files and cached data.", [ + { + text: "Cancel", + style: "cancel", + }, + { + text: "Clear", + + isPreferred: true, + onPress: async () => { + const cacheDir = FileSystem.cacheDirectory + if (cacheDir) { + await FileSystem.deleteAsync(cacheDir, { idempotent: true }) + } + toast.success("Cache cleared") + }, + }, + ]) + }} + label="Clear cache" + description="Clear temporary files and cached data to free up storage space." + /> + + + ) } diff --git a/apps/mobile/src/modules/settings/routes/Profile.tsx b/apps/mobile/src/modules/settings/routes/Profile.tsx index a446277e69..d3c1e8eb72 100644 --- a/apps/mobile/src/modules/settings/routes/Profile.tsx +++ b/apps/mobile/src/modules/settings/routes/Profile.tsx @@ -2,7 +2,7 @@ import type { FeedViewType } from "@follow/constants" import { cn } from "@follow/utils" import { Stack } from "expo-router" import { Fragment, useCallback, useEffect, useMemo } from "react" -import { FlatList, Image, Linking, Pressable, StyleSheet, Text, View } from "react-native" +import { FlatList, Image, Share, StyleSheet, Text, TouchableOpacity, View } from "react-native" import Animated, { useAnimatedScrollHandler, useSharedValue } from "react-native-reanimated" import { useSafeAreaInsets } from "react-native-safe-area-context" @@ -51,8 +51,12 @@ export const ProfileScreen = () => { const textLabelColor = useColor("label") const openShareUrl = useCallback(() => { if (!whoami?.id) return - Linking.openURL(`https://app.follow.is/share/users/${whoami.id}`) - }, [whoami?.id]) + Share.share({ + url: `https://app.follow.is/share/users/${whoami.id}`, + title: `Follow | ${whoami.name}'s Profile`, + }) + }, [whoami?.id, whoami?.name]) + return ( { {!isLoading && subscriptions && } {/* Top transparent header buttons */} - settingNavigation.goBack()} className="absolute left-4" style={{ top: insets.top }} > - + - + - + {/* Header */} { style={{ opacity: headerOpacity }} > - settingNavigation.goBack()}> + settingNavigation.goBack()}> - + {whoami?.name}'s Profile - + - + ) diff --git a/apps/mobile/src/modules/subscription/header-actions.tsx b/apps/mobile/src/modules/subscription/header-actions.tsx index ad2cff7a74..788fee52fe 100644 --- a/apps/mobile/src/modules/subscription/header-actions.tsx +++ b/apps/mobile/src/modules/subscription/header-actions.tsx @@ -2,11 +2,7 @@ import { TouchableOpacity } from "react-native" import type { ContextMenuAction } from "react-native-context-menu-view" import { DropdownMenu } from "@/src/components/ui/dropdown/DropdownMenu" -import { AZSortAscendingLettersCuteReIcon } from "@/src/icons/AZ_sort_ascending_letters_cute_re" -import { AZSortDescendingLettersCuteReIcon } from "@/src/icons/AZ_sort_descending_letters_cute_re" -import { Numbers90SortAscendingCuteReIcon } from "@/src/icons/numbers_90_sort_ascending_cute_re" -import { Numbers90SortDescendingCuteReIcon } from "@/src/icons/numbers_90_sort_descending_cute_re" -import { accentColor } from "@/src/theme/colors" +import { ListExpansionCuteReIcon } from "@/src/icons/list_expansion_cute_re" import { setFeedListSortMethod, @@ -15,65 +11,62 @@ import { useFeedListSortOrder, } from "./atoms" -const map = { - alphabet: { - asc: AZSortAscendingLettersCuteReIcon, - desc: AZSortDescendingLettersCuteReIcon, - }, - count: { - desc: Numbers90SortDescendingCuteReIcon, - asc: Numbers90SortAscendingCuteReIcon, - }, -} - export const SortActionButton = () => { const sortMethod = useFeedListSortMethod() const sortOrder = useFeedListSortOrder() - const orderActions: ContextMenuAction[] = [ - { title: "Ascending", selected: sortOrder === "asc" }, - { title: "Descending", selected: sortOrder === "desc" }, + const alphabetOrderActions: ContextMenuAction[] = [ + { title: "Ascending", selected: sortMethod === "alphabet" && sortOrder === "asc" }, + { title: "Descending", selected: sortMethod === "alphabet" && sortOrder === "desc" }, + ] + + const countOrderActions: ContextMenuAction[] = [ + { title: "Ascending", selected: sortMethod === "count" && sortOrder === "asc" }, + { title: "Descending", selected: sortMethod === "count" && sortOrder === "desc" }, ] const actions: ContextMenuAction[] = [ - { title: "Alphabet", actions: orderActions, selected: sortMethod === "alphabet" }, - { title: "Unread Count", actions: orderActions, selected: sortMethod === "count" }, + { + title: "Sort by Alphabet", + actions: alphabetOrderActions, + selected: sortMethod === "alphabet", + }, + { title: "Sort by Unread Count", actions: countOrderActions, selected: sortMethod === "count" }, ] - const Icon = map[sortMethod][sortOrder] return ( - { - const [firstArgs, secondary] = e.nativeEvent.indexPath + + { + const [firstArgs, secondary] = e.nativeEvent.indexPath - switch (firstArgs) { - case 0: { - setFeedListSortMethod("alphabet") - break - } - case 1: { - setFeedListSortMethod("count") - break + switch (firstArgs) { + case 0: { + setFeedListSortMethod("alphabet") + break + } + case 1: { + setFeedListSortMethod("count") + break + } } - } - switch (secondary) { - case 0: { - setFeedListSortOrder("asc") - break - } - case 1: { - setFeedListSortOrder("desc") - break + switch (secondary) { + case 0: { + setFeedListSortOrder("asc") + break + } + case 1: { + setFeedListSortOrder("desc") + break + } } - } - }} - > - - - - + }} + > + + + ) } diff --git a/apps/mobile/src/screens/(stack)/(tabs)/index.tsx b/apps/mobile/src/screens/(stack)/(tabs)/index.tsx index 4adee5550d..15e41c0a47 100644 --- a/apps/mobile/src/screens/(stack)/(tabs)/index.tsx +++ b/apps/mobile/src/screens/(stack)/(tabs)/index.tsx @@ -1,3 +1,4 @@ +import { useIsFocused } from "@react-navigation/native" import { Link, Stack } from "expo-router" import { useEffect } from "react" import { Text, TouchableOpacity, View } from "react-native" @@ -18,12 +19,15 @@ export default function Index() { usePrefetchUnread() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + const isFocused = useIsFocused() useEffect(() => { - setDrawerSwipeDisabled(false) - return () => { + if (isFocused) { + setDrawerSwipeDisabled(false) + } else { setDrawerSwipeDisabled(true) } - }, [setDrawerSwipeDisabled]) + }, [setDrawerSwipeDisabled, isFocused]) + return ( <> >({ @@ -42,7 +42,7 @@ export function LoginWithPassword({ runtime }: { runtime?: LoginRuntime }) { const { dismiss } = useCurrentModal() async function onSubmit(values: z.infer) { - const res = await loginHandler("credential", runtime ?? "browser", { + const res = await loginHandler("credential", runtime, { email: values.email, password: values.password, }) diff --git a/apps/renderer/src/modules/auth/LoginModalContent.tsx b/apps/renderer/src/modules/auth/LoginModalContent.tsx index 6cff44b31f..068db447ff 100644 --- a/apps/renderer/src/modules/auth/LoginModalContent.tsx +++ b/apps/renderer/src/modules/auth/LoginModalContent.tsx @@ -19,7 +19,7 @@ import { useAuthProviders } from "~/queries/users" import { LoginWithPassword } from "./Form" interface LoginModalContentProps { - runtime?: LoginRuntime + runtime: LoginRuntime canClose?: boolean } @@ -129,7 +129,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => { ))} - + ) @@ -196,7 +196,7 @@ const LoginButtonContent = (props: { children: React.ReactNode; isLoading: boole export const AuthProvidersRender: FC<{ providers: AuthProvider[] - runtime?: LoginRuntime + runtime: LoginRuntime }> = ({ providers, runtime }) => { const { t } = useTranslation() const [authProcessingLockSet, setAuthProcessingLockSet] = useState(() => new Set()) diff --git a/apps/renderer/src/modules/entry-column/Items/video-item.tsx b/apps/renderer/src/modules/entry-column/Items/video-item.tsx index 53f5a1593a..a254ac29f0 100644 --- a/apps/renderer/src/modules/entry-column/Items/video-item.tsx +++ b/apps/renderer/src/modules/entry-column/Items/video-item.tsx @@ -37,10 +37,12 @@ export function VideoItem({ entryId, entryPreview, translation }: UniversalItemP url: entry?.entries.url ?? "", mini: true, isIframe: !IN_ELECTRON, + attachments: entry?.entries.attachments, }), transformVideoUrl({ url: entry?.entries.url ?? "", isIframe: !IN_ELECTRON, + attachments: entry?.entries.attachments, }), ], [entry?.entries.url], diff --git a/icons/mgc/exit_cute_fi.svg b/icons/mgc/exit_cute_fi.svg new file mode 100644 index 0000000000..42d7bed3b2 --- /dev/null +++ b/icons/mgc/exit_cute_fi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/locales/app/ja.json b/locales/app/ja.json index d84bccc6a4..1bf46f0fd3 100644 --- a/locales/app/ja.json +++ b/locales/app/ja.json @@ -98,6 +98,9 @@ "discover.preview": "プレビュー", "discover.rss_hub_route": "RSSHub ルート", "discover.rss_url": "RSS URL", + "discover.search.results_one": "フィードが {{count}} 件みつかりました", + "discover.search.results_other": "フィードが {{count}} 件みつかりました", + "discover.search.results_zero": "フィードが見つかりませんでした", "discover.select_placeholder": "選択", "discover.target.feeds": "フィード", "discover.target.label": "検索対象", @@ -167,7 +170,7 @@ "entry_list_header.show_all": "すべて表示", "entry_list_header.show_all_items": "すべてのエントリーを表示", "entry_list_header.show_unread_only": "未読のみ表示", - "entry_list_header.switch_to_normalmode": "2列モードに切り替え", + "entry_list_header.switch_to_normalmode": "2 列モードに切り替え", "entry_list_header.switch_to_widemode": "ワイドモードに切り替え", "entry_list_header.unread": "未読", "feed.follower_one": "フォロワー", diff --git a/locales/errors/ja.json b/locales/errors/ja.json index 8ec5f92665..b0d166945e 100644 --- a/locales/errors/ja.json +++ b/locales/errors/ja.json @@ -26,6 +26,8 @@ "4004": "対象ユーザーのウォレットエラー", "4005": "デイリー Power を計算中です", "4006": "ブースト手数料の単位が正しくありません", + "4007": "2要素コードが正しくありません", + "4008": "2要素コードが必要です", "4010": "エアドロップは対象外です", "4011": "エアドロップは送信中です", "4012": "エアドロップは送信済みです", diff --git a/locales/external/ja.json b/locales/external/ja.json index dcfb3d7b75..2ccfe9f0f8 100644 --- a/locales/external/ja.json +++ b/locales/external/ja.json @@ -55,6 +55,7 @@ "login.signOut": "サインアウト", "login.signUp": "メールでサインアップ", "login.submit": "送信", + "login.two_factor.code": "2要素コード", "login.welcomeTo": "ようこそ ", "redirect.continueInBrowser": "ブラウザーで続行", "redirect.instruction": "今が{{app_name}}を開き、このページを安全に閉じる時です。", diff --git a/locales/settings/ja.json b/locales/settings/ja.json index 16deb5a186..1df2f6e90b 100644 --- a/locales/settings/ja.json +++ b/locales/settings/ja.json @@ -100,8 +100,8 @@ "customizeToolbar.title": "ツールバーをカスタマイズ", "data_control.app_cache_limit.description": "アプリの最大キャッシュを設定します。 このサイズに達すると空き容量を確保するために古いアイテムから削除されます。", "data_control.app_cache_limit.label": "キャッシュリミット", - "data_control.clean_cache.button": "キャッシュをクリアー", - "data_control.clean_cache.description": "空き容量を確保するためにキャッシュをクリアーします。", + "data_control.clean_cache.button": "キャッシュをクリア", + "data_control.clean_cache.description": "空き容量を確保するためにキャッシュをクリアします。", "data_control.clean_cache.description_web": "サービスワーカーのキャッシュを削除して空き容量を確保します。", "feeds.claimTips": "フィードを認証してチップを受け取るには、購読リストのフィードを右クリックして「フィードをクレーム」を選択してください。", "feeds.noFeeds": "認証されたフィードはありません", @@ -284,11 +284,23 @@ "profile.name.description": "公開表示名", "profile.name.label": "表示名", "profile.new_password.label": "新しいパスワード", + "profile.no_password": "パスワードを リセット します。", "profile.password.label": "パスワード", "profile.reset_password_mail_sent": "パスワードリセットメールを送信しました", "profile.sidebar_title": "プロフィール", "profile.submit": "送信", "profile.title": "プロフィール設定", + "profile.totp_code.init": "TOTP アプリで QR コードをスキャンしてください", + "profile.totp_code.invalid": "TOTP コードが正しくありません。", + "profile.totp_code.label": "TOTP コード", + "profile.totp_code.title": "TOTP コードを入力", + "profile.two_factor.disable": "2FA を無効にする", + "profile.two_factor.disabled": "2FA を無効化しました", + "profile.two_factor.enable": "2FA を有効にする ", + "profile.two_factor.enable_notice": "このアクションを実行するには 2FA の有効化が必要です。", + "profile.two_factor.enabled": "2FA が有効になりました", + "profile.two_factor.label": "2FA", + "profile.two_factor.no_password": "2FA を有効化する前にパスワードの 設定 が必要です。", "profile.updateSuccess": "プロフィールが更新されました。", "profile.update_password_success": "パスワードが更新されました。", "rsshub.addModal.access_key_label": "アクセスキー (オプション)", diff --git a/packages/shared/src/auth.ts b/packages/shared/src/auth.ts index 96b885797d..9495a02072 100644 --- a/packages/shared/src/auth.ts +++ b/packages/shared/src/auth.ts @@ -56,7 +56,6 @@ export const { updateUser, } = authClient -export const LOGIN_CALLBACK_URL = `${WEB_URL}/login` export type LoginRuntime = "browser" | "app" export const loginHandler = async ( provider: string, @@ -80,7 +79,7 @@ export const loginHandler = async ( signIn.social({ provider: provider as "google" | "github" | "apple", - callbackURL: runtime === "app" ? LOGIN_CALLBACK_URL : undefined, + callbackURL: runtime === "app" ? `${WEB_URL}/login` : WEB_URL, }) } } diff --git a/packages/utils/src/url-for-video.ts b/packages/utils/src/url-for-video.ts index 68b4bd9a74..444f00f475 100644 --- a/packages/utils/src/url-for-video.ts +++ b/packages/utils/src/url-for-video.ts @@ -2,10 +2,17 @@ export const transformVideoUrl = ({ url, mini = false, isIframe = false, + attachments, }: { url: string mini?: boolean isIframe?: boolean + attachments?: + | { + url: string + mime_type?: string + }[] + | null }) => { if (url?.match(/\/\/www.bilibili.com\/video\/BV\w+/)) { const player = isIframe @@ -30,5 +37,9 @@ export const transformVideoUrl = ({ }, ).toString()}` } + + if (attachments) { + return attachments.find((attachment) => attachment.mime_type === "text/html")?.url + } return null } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44acc184af..2f232a183c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -499,6 +499,9 @@ importers: expo-dev-client: specifier: ^5.0.9 version: 5.0.9(expo@52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@expo/metro-runtime@4.0.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)))(bufferutil@4.0.8)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)) + expo-document-picker: + specifier: ~13.0.2 + version: 13.0.2(expo@52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@expo/metro-runtime@4.0.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)))(bufferutil@4.0.8)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)) expo-file-system: specifier: ~18.0.6 version: 18.0.6(yviy5suycsk3aacmyts4vro3la) @@ -628,6 +631,9 @@ importers: react-native-webview: specifier: 13.12.5 version: 13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + shiki: + specifier: 1.24.1 + version: 1.24.1 swiftui-react-native: specifier: 6.3.3 version: 6.3.3(vc5zx7mqwgzirpvpjamp5nboge) @@ -8788,6 +8794,11 @@ packages: peerDependencies: expo: '*' + expo-document-picker@13.0.2: + resolution: {integrity: sha512-Ssnmgx6OTsFEBOx5ktVyJmD5q+7pnGvPRrBHppiJYwX65cREVZuuJ8xAPhoqPHYn65+4WjaxS1lP2rDkSsMo8w==} + peerDependencies: + expo: '*' + expo-drizzle-studio-plugin@0.1.1: resolution: {integrity: sha512-OfV79+crmAIMK0jlwmVOMRXdss5rEHtA8vDALPLkjisDSAVEwElzx4OEGuUG/Pf8QlTGUEr7kK3WtCoQvWzo8g==} peerDependencies: @@ -24730,6 +24741,10 @@ snapshots: expo: 52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@expo/metro-runtime@4.0.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)))(bufferutil@4.0.8)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) expo-dev-menu-interface: 1.9.2(expo@52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@expo/metro-runtime@4.0.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)))(bufferutil@4.0.8)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)) + expo-document-picker@13.0.2(expo@52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@expo/metro-runtime@4.0.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)))(bufferutil@4.0.8)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)): + dependencies: + expo: 52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@expo/metro-runtime@4.0.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)))(bufferutil@4.0.8)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + expo-drizzle-studio-plugin@0.1.1(vc5zx7mqwgzirpvpjamp5nboge): dependencies: expo: 52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@expo/metro-runtime@4.0.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)))(bufferutil@4.0.8)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)