├── .nvmrc ├── .npmrc ├── assets ├── icon.png ├── favicon.png ├── splash.png └── adaptive-icon.png ├── utils ├── invoke.ts ├── sleep.ts ├── useMount.ts ├── isExpoGo.ts ├── useLatest.ts ├── hasSize.ts ├── useEffectOnce.ts ├── useUpdate.ts ├── timeout.ts ├── useFirstMountState.ts ├── authentication.ts ├── request │ ├── paramsSerializer.ts │ └── index.ts ├── useScreenWidth.ts ├── dayjsPlugins.ts ├── useAppState.ts ├── useUnmount.ts ├── zodHelper.ts ├── useMountedState.ts ├── tw.ts ├── useUpdateEffect.ts ├── useRefreshByUser.ts ├── confirm.ts ├── usePreviousDistinct.ts ├── convertSelectedTextToBase64.tsx ├── tablet.ts ├── enabledNetworkInspect.ts ├── useStatusBarStyle.ts ├── cookie.ts ├── useNavigationBar.ts ├── useQueryData.ts ├── query.ts └── url.ts ├── jotai ├── store.ts ├── blockKeywordsAtom.ts ├── deviceTypeAtom.ts ├── searchHistoryAtom.ts ├── baseUrlAtom.ts ├── enabledWebviewAtom.ts ├── profileAtom.ts ├── deletedNamesAtom.ts ├── enabledMsgPushAtom.ts ├── open503UrlTimeAtom.ts ├── enabledAutoCheckinAtom.ts ├── enabledParseContent.ts ├── repliesMode.ts ├── imgurConfigAtom.ts ├── navNodesAtom.ts ├── imageViewerAtom.ts ├── blackListAtom.ts ├── utils │ └── atomWithAsyncStorage.ts ├── recentTopicsAtom.ts ├── sov2exArgsAtom.ts ├── topicDraftAtom.ts ├── homeTabsAtom.ts └── themeAtom.ts ├── tsconfig.json ├── .vscode └── settings.json ├── index.js ├── components ├── Html │ ├── HtmlContext.ts │ ├── InputRenderer.tsx │ ├── IFrameRenderer.tsx │ ├── TextRenderer.tsx │ ├── ImageRenderer.tsx │ ├── TextRenderer_todo.tsx │ └── helper.ts ├── StyledSwitch.tsx ├── placeholder │ ├── PlaceholderShape.tsx │ ├── Placeholder.tsx │ ├── PlaceholderLine.tsx │ └── TopicPlaceholder.tsx ├── StyledActivityIndicator.tsx ├── StyledText.tsx ├── StyledRefreshControl.tsx ├── LoadingIndicator.tsx ├── NodeItem.tsx ├── StyledImage │ ├── BrokenImage.tsx │ ├── index.tsx │ ├── Svg.tsx │ ├── helper.ts │ ├── AnimatedImageOverlay.tsx │ └── BaseImage.tsx ├── StyledTextInput.tsx ├── DebouncedPressable.tsx ├── RefetchingIndicator.tsx ├── Separator.tsx ├── UploadImageButton.tsx ├── Money.tsx ├── RadioButtonGroup.tsx ├── ListItem.tsx ├── StyledBlurView.tsx ├── Badge.tsx ├── DragableItem.tsx ├── AsyncStoragePersist.tsx ├── CollapsibleTabView.tsx ├── FormControl.tsx ├── Empty.tsx ├── StyledToast.tsx ├── IconButton.tsx ├── SearchBar.tsx ├── V2exWebview │ └── v2exMessage.ts ├── Drawer.tsx ├── StyledButton.tsx ├── StyledImageViewer.tsx ├── topic │ └── XnaItem.tsx └── NavBar.tsx ├── .eslintrc ├── .prettierrc.js ├── screens ├── NotFoundScreen.tsx ├── SelectableTextScreen.tsx ├── GItHubMDScreen.tsx ├── WebSigninScreen.tsx ├── MyTopicsScreen.tsx ├── MyNodesScreen.tsx ├── SearchNodeScreen.tsx ├── WebviewScreen.tsx ├── ConfigureDomainScreen.tsx └── NavNodesScreen.tsx ├── servicies ├── reply.ts ├── settings.ts ├── top.ts ├── index.ts ├── node.ts ├── notification.ts ├── my.ts ├── types.ts └── auth.ts ├── tailwind.config.js ├── eas.json ├── navigation ├── LinkingConfiguration.ts └── navigationRef.tsx ├── babel.config.js ├── patches ├── react-native-drag-sort+2.4.4.patch ├── react-native-image-zoom-viewer+3.0.1.patch └── react-native-render-html+6.3.4.patch ├── metro.config.js ├── store.config.json ├── LICENSE ├── .gitignore ├── terms-and-conditions_zh.md ├── privacy-policy_zh.md ├── README.md ├── types.tsx ├── package.json ├── App.tsx ├── terms-and-conditions_en.md ├── privacy-policy_en.md └── app.json /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.19.0 -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | registry=https://registry.npmjs.org 3 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liaoliao666/v2ex/HEAD/assets/icon.png -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liaoliao666/v2ex/HEAD/assets/favicon.png -------------------------------------------------------------------------------- /assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liaoliao666/v2ex/HEAD/assets/splash.png -------------------------------------------------------------------------------- /utils/invoke.ts: -------------------------------------------------------------------------------- 1 | export function invoke(fn: () => T): T { 2 | return fn() 3 | } 4 | -------------------------------------------------------------------------------- /assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liaoliao666/v2ex/HEAD/assets/adaptive-icon.png -------------------------------------------------------------------------------- /utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms: number) => 2 | new Promise(ok => setTimeout(() => ok(), ms)) 3 | -------------------------------------------------------------------------------- /jotai/store.ts: -------------------------------------------------------------------------------- 1 | import { unstable_createStore } from 'jotai' 2 | 3 | export const store = unstable_createStore() 4 | -------------------------------------------------------------------------------- /jotai/blockKeywordsAtom.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | 3 | export const blockKeywordsAtom = atom([]) 4 | -------------------------------------------------------------------------------- /jotai/deviceTypeAtom.ts: -------------------------------------------------------------------------------- 1 | import * as Device from 'expo-device' 2 | import { atom } from 'jotai' 3 | 4 | export const deviceTypeAtom = atom(Device.getDeviceTypeAsync) 5 | -------------------------------------------------------------------------------- /jotai/searchHistoryAtom.ts: -------------------------------------------------------------------------------- 1 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 2 | 3 | export const searchHistoryAtom = atomWithAsyncStorage( 4 | 'searchHistory', 5 | [] 6 | ) 7 | -------------------------------------------------------------------------------- /utils/useMount.ts: -------------------------------------------------------------------------------- 1 | import useEffectOnce from './useEffectOnce' 2 | 3 | const useMount = (fn: () => void) => { 4 | useEffectOnce(() => { 5 | fn() 6 | }) 7 | } 8 | 9 | export default useMount 10 | -------------------------------------------------------------------------------- /jotai/baseUrlAtom.ts: -------------------------------------------------------------------------------- 1 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 2 | 3 | export const v2exURL = `https://www.v2ex.com` 4 | 5 | export const baseUrlAtom = atomWithAsyncStorage('baseUrl', v2exURL) 6 | -------------------------------------------------------------------------------- /jotai/enabledWebviewAtom.ts: -------------------------------------------------------------------------------- 1 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 2 | 3 | /** 4 | * 打开链接时是否启用内置浏览器 5 | */ 6 | export const enabledWebviewAtom = atomWithAsyncStorage('enabledWebview', true) 7 | -------------------------------------------------------------------------------- /jotai/profileAtom.ts: -------------------------------------------------------------------------------- 1 | import { Profile } from '@/servicies' 2 | 3 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 4 | 5 | export const profileAtom = atomWithAsyncStorage('profile', null) 6 | -------------------------------------------------------------------------------- /utils/isExpoGo.ts: -------------------------------------------------------------------------------- 1 | import Constants, { ExecutionEnvironment } from 'expo-constants' 2 | 3 | // `true` when running in Expo Go. 4 | export const isExpoGo = 5 | Constants.executionEnvironment === ExecutionEnvironment.StoreClient 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "strict": true, 6 | "baseUrl": ".", 7 | "paths": { 8 | "@/*": ["./*"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /jotai/deletedNamesAtom.ts: -------------------------------------------------------------------------------- 1 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 2 | 3 | /** 4 | * 模拟注销帐号 5 | */ 6 | export const deletedNamesAtom = atomWithAsyncStorage( 7 | 'deletedNames', 8 | [] 9 | ) 10 | -------------------------------------------------------------------------------- /utils/useLatest.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | 3 | const useLatest = (value: T): { readonly current: T } => { 4 | const ref = useRef(value) 5 | ref.current = value 6 | return ref 7 | } 8 | 9 | export default useLatest 10 | -------------------------------------------------------------------------------- /jotai/enabledMsgPushAtom.ts: -------------------------------------------------------------------------------- 1 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 2 | 3 | /** 4 | * 是否启动消息推送 5 | */ 6 | export const enabledMsgPushAtom = atomWithAsyncStorage( 7 | 'enabledMsgPush', 8 | true 9 | ) 10 | -------------------------------------------------------------------------------- /jotai/open503UrlTimeAtom.ts: -------------------------------------------------------------------------------- 1 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 2 | 3 | /** 4 | * 今天是否打开了503页面 5 | */ 6 | export const open503UrlTimeAtom = atomWithAsyncStorage( 7 | 'open503UrlTime', 8 | 0 9 | ) 10 | -------------------------------------------------------------------------------- /utils/hasSize.ts: -------------------------------------------------------------------------------- 1 | import { isPlainObject } from 'lodash-es' 2 | 3 | export function hasSize( 4 | style: any 5 | ): style is { width: number; height: number; [k: string]: any } { 6 | return isPlainObject(style) && !!style.width && !!style.height 7 | } 8 | -------------------------------------------------------------------------------- /jotai/enabledAutoCheckinAtom.ts: -------------------------------------------------------------------------------- 1 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 2 | 3 | /** 4 | * 是否启动自动签到 5 | */ 6 | export const enabledAutoCheckinAtom = atomWithAsyncStorage( 7 | 'enabledAutoCheckin', 8 | true 9 | ) 10 | -------------------------------------------------------------------------------- /jotai/enabledParseContent.ts: -------------------------------------------------------------------------------- 1 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 2 | 3 | /** 4 | * 是否内容解析 5 | */ 6 | export const enabledParseContentAtom = atomWithAsyncStorage( 7 | 'enabledParseContent', 8 | true 9 | ) 10 | -------------------------------------------------------------------------------- /utils/useEffectOnce.ts: -------------------------------------------------------------------------------- 1 | import { EffectCallback, useEffect } from 'react' 2 | 3 | const useEffectOnce = (effect: EffectCallback) => { 4 | // eslint-disable-next-line react-hooks/exhaustive-deps 5 | useEffect(effect, []) 6 | } 7 | 8 | export default useEffectOnce 9 | -------------------------------------------------------------------------------- /jotai/repliesMode.ts: -------------------------------------------------------------------------------- 1 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 2 | 3 | export type RepliesMode = 'default' | 'smart' | 'reverse' 4 | 5 | export const repliesModeAtom = atomWithAsyncStorage( 6 | 'repliesMode', 7 | 'default' 8 | ) 9 | -------------------------------------------------------------------------------- /jotai/imgurConfigAtom.ts: -------------------------------------------------------------------------------- 1 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 2 | 3 | export const imgurConfigAtom = atomWithAsyncStorage<{ 4 | clientId?: string 5 | uploadedFiles: Record 6 | }>('imgurConfig', { 7 | uploadedFiles: {}, 8 | }) 9 | -------------------------------------------------------------------------------- /jotai/navNodesAtom.ts: -------------------------------------------------------------------------------- 1 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 2 | 3 | /** 4 | * 节点导航 5 | */ 6 | export const navNodesAtom = atomWithAsyncStorage< 7 | { 8 | title: string 9 | nodeNames: string[] 10 | }[] 11 | >('navNodes', []) 12 | -------------------------------------------------------------------------------- /utils/useUpdate.ts: -------------------------------------------------------------------------------- 1 | import { useReducer } from 'react' 2 | 3 | const updateReducer = (num: number): number => (num + 1) % 1_000_000 4 | 5 | export default function useUpdate(): () => void { 6 | const [, update] = useReducer(updateReducer, 0) 7 | 8 | return update 9 | } 10 | -------------------------------------------------------------------------------- /utils/timeout.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from './sleep' 2 | 3 | export function timeout(p: Promise, ms: number): Promise { 4 | return Promise.race([ 5 | p, 6 | sleep(ms).then(() => 7 | Promise.reject(new Error('Timeout after ' + ms + ' ms')) 8 | ), 9 | ]) 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "tailwindCSS.experimental.classRegex": [ 3 | "tw`([^`]*)", 4 | "tw\\.style\\(([^)]*)\\)", 5 | "tw\\.color\\(([^)]*)\\)", 6 | ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], 7 | ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /utils/useFirstMountState.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | 3 | export function useFirstMountState(): boolean { 4 | const isFirst = useRef(true) 5 | 6 | if (isFirst.current) { 7 | isFirst.current = false 8 | 9 | return true 10 | } 11 | 12 | return isFirst.current 13 | } 14 | -------------------------------------------------------------------------------- /jotai/imageViewerAtom.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | 3 | import { StyledImageViewerProps } from '@/components/StyledImageViewer' 4 | 5 | /** 6 | * 图片预览 7 | */ 8 | export const imageViewerAtom = atom({ 9 | index: 0, 10 | visible: false, 11 | imageUrls: [], 12 | }) 13 | -------------------------------------------------------------------------------- /utils/authentication.ts: -------------------------------------------------------------------------------- 1 | import { profileAtom } from '@/jotai/profileAtom' 2 | import { store } from '@/jotai/store' 3 | 4 | export function isSignined() { 5 | return !!store.get(profileAtom) 6 | } 7 | 8 | export function isSelf(username?: string) { 9 | return store.get(profileAtom)?.username === username 10 | } 11 | -------------------------------------------------------------------------------- /utils/request/paramsSerializer.ts: -------------------------------------------------------------------------------- 1 | export function paramsSerializer(params: Record) { 2 | return Object.entries(params) 3 | .flatMap(([key, val]) => 4 | (Array.isArray(val) ? val : [val]).map( 5 | v => `${key}=${encodeURIComponent(v ?? '')}` 6 | ) 7 | ) 8 | .join('&') 9 | } 10 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { registerRootComponent } from 'expo' 2 | 3 | import App from './App' 4 | 5 | // registerRootComponent calls AppRegistry.registerComponent('main', () => App); 6 | // It also ensures that whether you load the app in Expo Go or in a native build, 7 | // the environment is set up appropriately 8 | registerRootComponent(App) 9 | -------------------------------------------------------------------------------- /components/Html/HtmlContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | 3 | export const HtmlContext = createContext<{ 4 | onPreview: (url: string) => void 5 | paddingX: number 6 | inModalScreen?: boolean 7 | onSelectText: () => void 8 | selectOnly?: boolean 9 | }>({ onPreview: () => {}, paddingX: 32, onSelectText: () => {} }) 10 | -------------------------------------------------------------------------------- /jotai/blackListAtom.ts: -------------------------------------------------------------------------------- 1 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 2 | 3 | export type HomeTab = { 4 | title: string 5 | key: string 6 | } 7 | 8 | export const blackListAtom = atomWithAsyncStorage<{ 9 | ignoredTopics: number[] 10 | blockers: number[] 11 | }>('blackList', { 12 | ignoredTopics: [], 13 | blockers: [], 14 | }) 15 | -------------------------------------------------------------------------------- /utils/useScreenWidth.ts: -------------------------------------------------------------------------------- 1 | import { useWindowDimensions } from 'react-native' 2 | 3 | import { useTablet } from './tablet' 4 | 5 | /** 6 | * 屏幕宽度 7 | * 适配 iPad 布局 8 | */ 9 | export function useScreenWidth() { 10 | const { width } = useWindowDimensions() 11 | const { isTablet, navbarWidth } = useTablet() 12 | return isTablet ? width - navbarWidth : width 13 | } 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@react-native-community"], 3 | "rules": { 4 | "react/react-in-jsx-scope": "off", 5 | "semi": "off", 6 | "prettier/prettier": 0, 7 | "react/no-unstable-nested-components": "off", 8 | "curly": "off", 9 | "quotes": "off", 10 | "react-native/no-inline-styles": "off", 11 | "no-useless-escape": "off" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /jotai/utils/atomWithAsyncStorage.ts: -------------------------------------------------------------------------------- 1 | import AsyncStorage from '@react-native-async-storage/async-storage' 2 | import { atomWithStorage, createJSONStorage } from 'jotai/utils' 3 | 4 | const storage = createJSONStorage(() => AsyncStorage) 5 | 6 | export function atomWithAsyncStorage(key: string, initialValue: T) { 7 | return atomWithStorage(key, initialValue, storage) 8 | } 9 | -------------------------------------------------------------------------------- /utils/dayjsPlugins.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import 'dayjs/locale/zh-cn' 3 | import relativeTime from 'dayjs/plugin/relativeTime' 4 | import timezone from 'dayjs/plugin/timezone' 5 | import utc from 'dayjs/plugin/utc' 6 | 7 | dayjs.extend(utc) 8 | dayjs.extend(timezone) 9 | 10 | dayjs.tz.setDefault('PRC') 11 | 12 | dayjs.locale('zh-cn') 13 | dayjs.extend(relativeTime) 14 | -------------------------------------------------------------------------------- /utils/useAppState.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { AppState, AppStateStatus } from 'react-native' 3 | 4 | export function useAppState(onChange: (status: AppStateStatus) => void) { 5 | useEffect(() => { 6 | const listener = AppState.addEventListener('change', onChange) 7 | return () => { 8 | listener.remove() 9 | } 10 | }, [onChange]) 11 | } 12 | -------------------------------------------------------------------------------- /jotai/recentTopicsAtom.ts: -------------------------------------------------------------------------------- 1 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 2 | 3 | export interface RecentTopic { 4 | member: { 5 | username: string 6 | avatar: string 7 | } 8 | id: number 9 | title: string 10 | } 11 | 12 | /** 13 | * 最近浏览节点 14 | */ 15 | export const recentTopicsAtom = atomWithAsyncStorage( 16 | 'recentTopics', 17 | [] 18 | ) 19 | -------------------------------------------------------------------------------- /utils/useUnmount.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | 3 | import useEffectOnce from './useEffectOnce' 4 | 5 | const useUnmount = (fn: () => any): void => { 6 | const fnRef = useRef(fn) 7 | 8 | // update the ref each render so if it change the newest callback will be invoked 9 | fnRef.current = fn 10 | 11 | useEffectOnce(() => () => fnRef.current()) 12 | } 13 | 14 | export default useUnmount 15 | -------------------------------------------------------------------------------- /utils/zodHelper.ts: -------------------------------------------------------------------------------- 1 | import { isString } from 'twrnc/dist/esm/types' 2 | 3 | export const stripString = (value: unknown) => { 4 | if (typeof value === 'string') { 5 | value = value.trim() 6 | return value === '' ? undefined : value 7 | } 8 | return value 9 | } 10 | 11 | export const stripStringToNumber = (value: unknown) => { 12 | const str = stripString(value) 13 | return isString(str) ? +str : str 14 | } 15 | -------------------------------------------------------------------------------- /jotai/sov2exArgsAtom.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | import { Sov2exArgs } from '@/servicies/other' 4 | 5 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 6 | 7 | /** 8 | * 搜索条件持久化 9 | */ 10 | export const sov2exArgsAtom = atomWithAsyncStorage>( 11 | 'sov2exArgs', 12 | { 13 | size: 20, 14 | sort: 'created', 15 | order: '0', 16 | source: 'sov2ex', 17 | } 18 | ) 19 | -------------------------------------------------------------------------------- /utils/useMountedState.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react' 2 | 3 | export default function useMountedState(): () => boolean { 4 | const mountedRef = useRef(false) 5 | const get = useCallback(() => mountedRef.current, []) 6 | 7 | useEffect(() => { 8 | mountedRef.current = true 9 | 10 | return () => { 11 | mountedRef.current = false 12 | } 13 | }, []) 14 | 15 | return get 16 | } 17 | -------------------------------------------------------------------------------- /utils/tw.ts: -------------------------------------------------------------------------------- 1 | import { TailwindFn, create } from 'twrnc' 2 | 3 | const tw = create(require('../tailwind.config.js')) as TailwindFn & { 4 | setColorScheme: (theme: 'dark' | 'light') => void 5 | } 6 | 7 | tw.color = utils => { 8 | const styleObj = tw.style(utils) 9 | const color = 10 | styleObj.color || styleObj.backgroundColor || styleObj.borderColor 11 | return typeof color === `string` ? color : undefined 12 | } 13 | 14 | export default tw 15 | -------------------------------------------------------------------------------- /utils/useUpdateEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | import { useFirstMountState } from './useFirstMountState' 4 | 5 | const useUpdateEffect: typeof useEffect = (effect, deps) => { 6 | const isFirstMount = useFirstMountState() 7 | 8 | useEffect(() => { 9 | if (!isFirstMount) { 10 | return effect() 11 | } 12 | // eslint-disable-next-line react-hooks/exhaustive-deps 13 | }, deps) 14 | } 15 | 16 | export default useUpdateEffect 17 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | tabWidth: 2, 4 | useTabs: false, 5 | semi: false, 6 | singleQuote: true, 7 | trailingComma: 'es5', 8 | bracketSpacing: true, 9 | jsxBracketSameLine: false, 10 | arrowParens: 'avoid', 11 | endOfLine: 'auto', 12 | plugins: [require('@trivago/prettier-plugin-sort-imports')], 13 | importOrder: ['^@/(.*)$', '^[./]'], 14 | importOrderSeparation: true, 15 | importOrderSortSpecifiers: true, 16 | } 17 | -------------------------------------------------------------------------------- /components/StyledSwitch.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { Switch, SwitchProps } from 'react-native' 3 | 4 | import { uiAtom } from '@/jotai/uiAtom' 5 | 6 | export default function StyledSwitch(props: SwitchProps) { 7 | const { colors } = useAtomValue(uiAtom) 8 | 9 | return ( 10 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /screens/NotFoundScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { View } from 'react-native' 3 | 4 | import Empty from '@/components/Empty' 5 | import { colorSchemeAtom } from '@/jotai/themeAtom' 6 | import tw from '@/utils/tw' 7 | 8 | export default function NotFoundScreen() { 9 | const colorScheme = useAtomValue(colorSchemeAtom) 10 | return ( 11 | 12 | 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /utils/useRefreshByUser.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export function useRefreshByUser(refetch: () => Promise) { 4 | const [isRefetchingByUser, setIsRefetchingByUser] = React.useState(false) 5 | 6 | async function refetchByUser() { 7 | setIsRefetchingByUser(true) 8 | 9 | try { 10 | await refetch() 11 | } finally { 12 | setIsRefetchingByUser(false) 13 | } 14 | } 15 | 16 | return { 17 | isRefetchingByUser, 18 | refetchByUser, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /servicies/reply.ts: -------------------------------------------------------------------------------- 1 | import { router } from 'react-query-kit' 2 | 3 | import { request } from '@/utils/request' 4 | 5 | export const replyRouter = router(`reply`, { 6 | thank: router.mutation({ 7 | mutationFn: ({ id, once }) => 8 | request.post(`/thank/reply/${id}?once=${once}`), 9 | }), 10 | 11 | ignore: router.mutation({ 12 | mutationFn: ({ id, once }) => 13 | request.post(`/ignore/reply/${id}?once=${once}`), 14 | }), 15 | }) 16 | -------------------------------------------------------------------------------- /components/placeholder/PlaceholderShape.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { ViewProps, ViewStyle, View } from "react-native"; 3 | 4 | interface PlaceholderShapeProps extends ViewProps { 5 | color?: string; 6 | } 7 | 8 | export const PlaceholderShape = memo(function PlaceholderComp({ 9 | color, 10 | ...props 11 | }: PlaceholderShapeProps) { 12 | const placeholderShapeStyle: ViewStyle = { 13 | backgroundColor: color, 14 | }; 15 | 16 | return ( 17 | 21 | ); 22 | }); 23 | -------------------------------------------------------------------------------- /components/StyledActivityIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { ActivityIndicator, ActivityIndicatorProps } from 'react-native' 3 | 4 | import { colorsAtom } from '@/jotai/uiAtom' 5 | import tw from '@/utils/tw' 6 | 7 | const StyledActivityIndicator = (props: ActivityIndicatorProps) => { 8 | const colors = useAtomValue(colorsAtom) 9 | const color = tw.color( 10 | `android:text-[${colors.default.light}] dark:text-[${colors.foreground.dark}]` 11 | )! 12 | 13 | return 14 | } 15 | 16 | export default StyledActivityIndicator 17 | -------------------------------------------------------------------------------- /components/StyledText.tsx: -------------------------------------------------------------------------------- 1 | import { Platform, Text, TextInput, TextProps, View } from 'react-native' 2 | 3 | export function MonoText(props: TextProps) { 4 | return 5 | } 6 | 7 | export default function StyledText({ children, ...props }: TextProps) { 8 | return Platform.OS === 'ios' ? ( 9 | 10 | 16 | 17 | ) : ( 18 | 19 | {children} 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /servicies/settings.ts: -------------------------------------------------------------------------------- 1 | import { router } from 'react-query-kit' 2 | 3 | import { profileAtom } from '@/jotai/profileAtom' 4 | import { store } from '@/jotai/store' 5 | import { request } from '@/utils/request' 6 | 7 | export const settingsRouter = router(`settings`, { 8 | resetBlockers: router.mutation({ 9 | mutationFn: () => 10 | request.get( 11 | `/settings/reset/blocked?once=${store.get(profileAtom)?.once}` 12 | ), 13 | }), 14 | 15 | resetIgnoredTopics: router.mutation({ 16 | mutationFn: () => 17 | request.get( 18 | `/settings/reset/ignored_topics?once=${store.get(profileAtom)?.once}` 19 | ), 20 | }), 21 | }) 22 | -------------------------------------------------------------------------------- /utils/confirm.ts: -------------------------------------------------------------------------------- 1 | import { Alert } from 'react-native' 2 | 3 | import { store } from '@/jotai/store' 4 | import { colorSchemeAtom } from '@/jotai/themeAtom' 5 | 6 | export function confirm(title: string, message?: string) { 7 | return new Promise((resolve, reject) => 8 | Alert.alert( 9 | title, 10 | message, 11 | [ 12 | { 13 | text: '取消', 14 | onPress: reject, 15 | style: 'cancel', 16 | }, 17 | { 18 | text: '确定', 19 | onPress: resolve, 20 | }, 21 | ], 22 | { 23 | userInterfaceStyle: store.get(colorSchemeAtom), 24 | } 25 | ) 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /servicies/top.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio' 2 | import { router } from 'react-query-kit' 3 | 4 | import { request } from '@/utils/request' 5 | 6 | import { parseRank } from './helper' 7 | 8 | export const topRouter = router(`top`, { 9 | rich: router.query({ 10 | fetcher: async () => { 11 | const { data } = await request(`/top/rich`, { 12 | responseType: 'text', 13 | }) 14 | return parseRank(load(data)) 15 | }, 16 | }), 17 | 18 | player: router.query({ 19 | fetcher: async () => { 20 | const { data } = await request(`/top/player`, { 21 | responseType: 'text', 22 | }) 23 | return parseRank(load(data)) 24 | }, 25 | }), 26 | }) 27 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './screens/**/*.{js,ts,jsx,tsx}', 4 | './pages/**/*.{js,ts,jsx,tsx}', 5 | './components/**/*.{js,ts,jsx,tsx}', 6 | './navigation/**/*.{js,ts,jsx,tsx}', 7 | ], 8 | theme: { 9 | extend: { 10 | colors: { 11 | primary: 'rgb(29,155,240)', 12 | 'primary-focus': 'rgb(26,140,216)', 13 | danger: `rgb(249,24,128)`, 14 | 'danger-focus': `rgba(249,24,128,.8)`, 15 | }, 16 | }, 17 | screens: { 18 | sm: '380px', 19 | md: '420px', 20 | lg: '680px', 21 | // or maybe name them after devices for `tablet:flex-row` 22 | tablet: '1024px', 23 | }, 24 | }, 25 | } 26 | -------------------------------------------------------------------------------- /utils/usePreviousDistinct.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | 3 | import { useFirstMountState } from './useFirstMountState' 4 | 5 | export type Predicate = (prev: T | undefined, next: T) => boolean 6 | 7 | const strictEquals = (prev: T | undefined, next: T) => prev === next 8 | 9 | export default function usePreviousDistinct( 10 | value: T, 11 | compare: Predicate = strictEquals 12 | ): T | undefined { 13 | const prevRef = useRef() 14 | const curRef = useRef(value) 15 | const isFirstMount = useFirstMountState() 16 | 17 | if (!isFirstMount && !compare(curRef.current, value)) { 18 | prevRef.current = curRef.current 19 | curRef.current = value 20 | } 21 | 22 | return prevRef.current 23 | } 24 | -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 3.0.0" 4 | }, 5 | "build": { 6 | "development": { 7 | "developmentClient": true, 8 | "distribution": "internal" 9 | }, 10 | "development-simulator": { 11 | "developmentClient": true, 12 | "distribution": "internal", 13 | "ios": { 14 | "simulator": true 15 | } 16 | }, 17 | "preview": { 18 | "distribution": "internal", 19 | "android": { 20 | "buildType": "apk", 21 | "gradleCommand": ":app:assembleRelease" 22 | } 23 | }, 24 | "production": { 25 | "ios": { 26 | "image": "latest" 27 | } 28 | } 29 | }, 30 | 31 | "submit": { 32 | "production": {} 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /components/StyledRefreshControl.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { RefreshControl, RefreshControlProps } from 'react-native' 3 | 4 | import { colorSchemeAtom } from '@/jotai/themeAtom' 5 | import { uiAtom } from '@/jotai/uiAtom' 6 | 7 | export default function StyledRefreshControl({ 8 | tintColor, 9 | ...props 10 | }: RefreshControlProps) { 11 | const ui = useAtomValue(uiAtom) 12 | const colorScheme = useAtomValue(colorSchemeAtom) 13 | const color = tintColor || (colorScheme === 'dark' ? '#ffffff' : '#000000') 14 | 15 | return ( 16 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /navigation/LinkingConfiguration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about deep linking with React Navigation 3 | * https://reactnavigation.org/docs/deep-linking 4 | * https://reactnavigation.org/docs/configuring-links 5 | */ 6 | import { LinkingOptions } from '@react-navigation/native' 7 | import * as Linking from 'expo-linking' 8 | 9 | import { RootStackParamList } from '../types' 10 | 11 | const linking: LinkingOptions = { 12 | prefixes: [Linking.createURL('/')], 13 | config: { 14 | screens: { 15 | Search: 'search/:query?', 16 | TopicDetail: 'topic/:id', 17 | MemberDetail: 'member/:username', 18 | NodeTopics: 'node/:name', 19 | NotFound: '*', 20 | }, 21 | }, 22 | } 23 | 24 | export default linking 25 | -------------------------------------------------------------------------------- /components/LoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ActivityIndicatorProps, Platform, ViewStyle } from 'react-native' 3 | import { SafeAreaView } from 'react-native-safe-area-context' 4 | 5 | import tw from '@/utils/tw' 6 | 7 | import StyledActivityIndicator from './StyledActivityIndicator' 8 | 9 | export default function LoadingIndicator({ 10 | style, 11 | size = Platform.OS === 'ios' ? 'small' : 'large', 12 | }: { 13 | style?: ViewStyle 14 | size?: ActivityIndicatorProps['size'] 15 | }) { 16 | return ( 17 | 21 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /utils/convertSelectedTextToBase64.tsx: -------------------------------------------------------------------------------- 1 | import { encode } from 'js-base64' 2 | import { TextInputSelectionChangeEventData } from 'react-native' 3 | import Toast from 'react-native-toast-message' 4 | 5 | export function convertSelectedTextToBase64( 6 | content: string = '', 7 | selection: TextInputSelectionChangeEventData['selection'] | void 8 | ) { 9 | if (!selection || selection.start === selection.end) { 10 | Toast.show({ 11 | type: 'error', 12 | text1: '请选择文字后再点击', 13 | }) 14 | return 15 | } 16 | 17 | const replacedText = `${content.substring(0, selection.start)}${encode( 18 | content.substring(selection.start, selection.end) 19 | )}${content.substring(selection.end, content.length)}` 20 | 21 | return replacedText 22 | } 23 | -------------------------------------------------------------------------------- /servicies/index.ts: -------------------------------------------------------------------------------- 1 | import { authRouter } from './auth' 2 | import { memberRouter } from './member' 3 | import { myRouter } from './my' 4 | import { nodeRouter } from './node' 5 | import { notificationRouter } from './notification' 6 | import { otherRouter } from './other' 7 | import { replyRouter } from './reply' 8 | import { settingsRouter } from './settings' 9 | import { topRouter } from './top' 10 | import { topicRouter } from './topic' 11 | 12 | export * from './types' 13 | 14 | export const k = { 15 | auth: authRouter, 16 | other: otherRouter, 17 | member: memberRouter, 18 | my: myRouter, 19 | node: nodeRouter, 20 | notification: notificationRouter, 21 | reply: replyRouter, 22 | settings: settingsRouter, 23 | top: topRouter, 24 | topic: topicRouter, 25 | } 26 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const ReactCompilerConfig = { 2 | target: '19', 3 | } 4 | 5 | module.exports = function (api) { 6 | api.cache(true) 7 | 8 | return { 9 | presets: ['babel-preset-expo'], 10 | plugins: [ 11 | ['babel-plugin-react-compiler', ReactCompilerConfig], 12 | [ 13 | 'module-resolver', 14 | { 15 | root: ['.'], 16 | extensions: [ 17 | '.js', 18 | '.jsx', 19 | '.ts', 20 | '.tsx', 21 | '.android.js', 22 | '.android.tsx', 23 | '.ios.js', 24 | '.ios.tsx', 25 | ], 26 | alias: { 27 | '@': './', 28 | }, 29 | }, 30 | ], 31 | 'react-native-reanimated/plugin', 32 | ], 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /patches/react-native-drag-sort+2.4.4.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/react-native-drag-sort/AnySizeDragSortableView.js b/node_modules/react-native-drag-sort/AnySizeDragSortableView.js 2 | index 9450cbc..077e11f 100644 3 | --- a/node_modules/react-native-drag-sort/AnySizeDragSortableView.js 4 | +++ b/node_modules/react-native-drag-sort/AnySizeDragSortableView.js 5 | @@ -1,6 +1,6 @@ 6 | import React from 'react'; 7 | import { 8 | - NativeModules, 9 | + UIManager, 10 | StyleSheet, 11 | ScrollView, 12 | View, 13 | @@ -10,7 +10,6 @@ import { 14 | } from 'react-native'; 15 | const PropTypes = require('prop-types') 16 | const ANIM_DURATION = 300 17 | -const { UIManager } = NativeModules; 18 | 19 | if (Platform.OS === 'android') { 20 | if (UIManager.setLayoutAnimationEnabledExperimental) { 21 | -------------------------------------------------------------------------------- /metro.config.js: -------------------------------------------------------------------------------- 1 | const { getDefaultConfig } = require('expo/metro-config'); 2 | 3 | /** @type {import('expo/metro-config').MetroConfig} */ 4 | const config = getDefaultConfig(__dirname); 5 | 6 | config.resolver.unstable_enablePackageExports = false; 7 | config.resolver.unstable_conditionNames = ["require"]; 8 | 9 | // Add custom resolveRequest function 10 | config.resolver.resolveRequest = (context, moduleName, platform) => { 11 | if (moduleName === "axios") { 12 | // Specifically use 'browser' condition for axios 13 | return context.resolveRequest( 14 | { ...context, unstable_conditionNames: ["browser"] }, 15 | moduleName, 16 | platform 17 | ); 18 | } 19 | // Fallback to default resolver for other modules 20 | return context.resolveRequest(context, moduleName, platform); 21 | }; 22 | 23 | module.exports = config; -------------------------------------------------------------------------------- /jotai/topicDraftAtom.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial } from 'react-hook-form' 2 | import { z } from 'zod' 3 | 4 | import { stripString } from '@/utils/zodHelper' 5 | 6 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 7 | 8 | export const topicDraftAtom = atomWithAsyncStorage< 9 | DeepPartial> 10 | >('topicDraft', { 11 | syntax: 'default', 12 | title: '', 13 | content: '', 14 | node: null as any, 15 | }) 16 | 17 | export const WriteTopicArgs = z.object({ 18 | title: z.preprocess(stripString, z.string()), 19 | content: z.preprocess(stripString, z.string().optional()), 20 | node: z.preprocess( 21 | stripString, 22 | z.object({ 23 | title: z.string(), 24 | name: z.string(), 25 | }) 26 | ), 27 | syntax: z.enum(['default', 'markdown']), 28 | }) 29 | -------------------------------------------------------------------------------- /utils/tablet.ts: -------------------------------------------------------------------------------- 1 | import * as Device from 'expo-device' 2 | import { useAtomValue } from 'jotai' 3 | import { Dimensions, Platform, useWindowDimensions } from 'react-native' 4 | 5 | import { deviceTypeAtom } from '../jotai/deviceTypeAtom' 6 | import { store } from '../jotai/store' 7 | 8 | export const isTablet = () => { 9 | const deviceType = store.get(deviceTypeAtom) 10 | const tablet = 11 | deviceType === Device.DeviceType.TABLET || 12 | deviceType === Device.DeviceType.DESKTOP 13 | 14 | return Platform.OS === 'ios' 15 | ? tablet 16 | : tablet || Dimensions.get('window').width >= 700 17 | } 18 | 19 | export const useTablet = () => { 20 | useAtomValue(deviceTypeAtom) 21 | const { width } = useWindowDimensions() 22 | return { 23 | navbarWidth: isTablet() ? Math.min(Math.floor((3 / 7) * width), 460) : 0, 24 | isTablet: isTablet(), 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /components/Html/InputRenderer.tsx: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio' 2 | import { useMemo } from 'react' 3 | import { Text } from 'react-native' 4 | import { CustomBlockRenderer } from 'react-native-render-html' 5 | 6 | const InputRenderer: CustomBlockRenderer = ({ tnode, style }) => { 7 | const { isCheckbox, isRadio, isChecked } = useMemo(() => { 8 | const $ = load(tnode.domNode as unknown as string) 9 | const $input = $('input') 10 | const type = $input.attr('type') 11 | 12 | return { 13 | isCheckbox: type === 'checkbox', 14 | isRadio: type === 'radio', 15 | isChecked: !!$input.attr('checked'), 16 | } 17 | }, [tnode.domNode]) 18 | 19 | return isCheckbox || isRadio ? ( 20 | 21 | {isCheckbox ? (isChecked ? `▣` : `▢`) : isChecked ? `◉` : `◎`} 22 | 23 | ) : null 24 | } 25 | 26 | export default InputRenderer 27 | -------------------------------------------------------------------------------- /utils/enabledNetworkInspect.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | export function enabledNetworkInspect() { 4 | global.XMLHttpRequest = global.originalXMLHttpRequest 5 | ? global.originalXMLHttpRequest 6 | : global.XMLHttpRequest 7 | global.FormData = global.originalFormData 8 | ? global.originalFormData 9 | : global.FormData 10 | 11 | fetch // Ensure to get the lazy property 12 | 13 | if (window.__FETCH_SUPPORT__) { 14 | // it's RNDebugger only to have 15 | window.__FETCH_SUPPORT__.blob = false 16 | } else { 17 | /* 18 | * Set __FETCH_SUPPORT__ to false is just work for `fetch`. 19 | * If you're using another way you can just use the native Blob and remove the `else` statement 20 | */ 21 | global.Blob = global.originalBlob ? global.originalBlob : global.Blob 22 | global.FileReader = global.originalFileReader 23 | ? global.originalFileReader 24 | : global.FileReader 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /store.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "configVersion": 0, 3 | "apple": { 4 | "version": "1.0", 5 | "release": { 6 | "automaticRelease": true 7 | }, 8 | "info": { 9 | "zh-Hans": { 10 | "title": "v2fun" 11 | } 12 | }, 13 | "advisory": { 14 | "alcoholTobaccoOrDrugUseOrReferences": "NONE", 15 | "contests": "NONE", 16 | "gamblingSimulated": "NONE", 17 | "horrorOrFearThemes": "NONE", 18 | "matureOrSuggestiveThemes": "NONE", 19 | "medicalOrTreatmentInformation": "NONE", 20 | "profanityOrCrudeHumor": "NONE", 21 | "sexualContentGraphicAndNudity": "NONE", 22 | "sexualContentOrNudity": "NONE", 23 | "violenceCartoonOrFantasy": "NONE", 24 | "violenceRealistic": "NONE", 25 | "violenceRealisticProlongedGraphicOrSadistic": "NONE", 26 | "gambling": false, 27 | "unrestrictedWebAccess": false, 28 | "kidsAgeBand": null, 29 | "seventeenPlus": false 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /components/NodeItem.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { compact } from 'lodash-es' 3 | import { memo } from 'react' 4 | import { Text, TouchableOpacity } from 'react-native' 5 | 6 | import { uiAtom } from '@/jotai/uiAtom' 7 | import { Node } from '@/servicies' 8 | import tw from '@/utils/tw' 9 | 10 | import { NAV_BAR_HEIGHT } from './NavBar' 11 | import StyledImage from './StyledImage' 12 | 13 | export default memo(NodeItem) 14 | 15 | function NodeItem({ node, onPress }: { node: Node; onPress?: () => void }) { 16 | const { colors, fontSize } = useAtomValue(uiAtom) 17 | return ( 18 | 22 | 23 | 24 | {compact([node.title, node.name]).join(' / ')} 25 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /components/placeholder/Placeholder.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, View, ViewProps } from "react-native"; 2 | 3 | export interface PlaceholderProps extends ViewProps { 4 | /* An optional component to display on the left */ 5 | Left?: React.ComponentType; 6 | /* An optional component to display on the right */ 7 | Right?: React.ComponentType; 8 | } 9 | 10 | const styles = StyleSheet.create({ 11 | full: { 12 | flex: 1, 13 | }, 14 | left: { 15 | marginRight: 12, 16 | }, 17 | right: { 18 | marginLeft: 12, 19 | }, 20 | row: { flexDirection: "row", width: "100%" }, 21 | }) 22 | 23 | export const Placeholder = ({ 24 | Left, 25 | Right, 26 | style, 27 | children, 28 | ...props 29 | }: PlaceholderProps) => { 30 | return ( 31 | 32 | {Left && } 33 | {children} 34 | {Right && } 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /components/placeholder/PlaceholderLine.tsx: -------------------------------------------------------------------------------- 1 | import { ViewProps, ViewStyle } from "react-native" 2 | import { PlaceholderShape } from "./PlaceholderShape"; 3 | 4 | export interface PlaceholderLineProps extends ViewProps { 5 | /* The line height, default is 12 */ 6 | height?: number; 7 | /* The line color, default is #efefef */ 8 | color?: string; 9 | /* The line width in percent, default is 100(%) */ 10 | width?: number; 11 | /* Defines if a line should have a margin bottom or not, default is false */ 12 | noMargin?: boolean; 13 | } 14 | 15 | export const PlaceholderLine = ({ width = 100, height = 12, color = '#efefef', noMargin, ...props }: PlaceholderLineProps) => { 16 | const placeholderLineStyle: ViewStyle = { 17 | width: `${width}%`, 18 | height: height, 19 | backgroundColor: color, 20 | borderRadius: height / 4, 21 | marginBottom: noMargin ? 0 : height, 22 | overflow: 'hidden', 23 | } 24 | return 25 | } 26 | -------------------------------------------------------------------------------- /components/StyledImage/BrokenImage.tsx: -------------------------------------------------------------------------------- 1 | import { MaterialCommunityIcons } from '@expo/vector-icons' 2 | import { useAtomValue } from 'jotai' 3 | import { StyleProp, TouchableOpacity, ViewStyle } from 'react-native' 4 | 5 | import { uiAtom } from '@/jotai/uiAtom' 6 | import { hasSize } from '@/utils/hasSize' 7 | import tw from '@/utils/tw' 8 | 9 | import { BROKEN_IMAGE_SIZE } from './helper' 10 | 11 | export default function BrokenImage({ 12 | onPress, 13 | style, 14 | }: { 15 | onPress: () => void 16 | style: StyleProp 17 | }) { 18 | const { colors } = useAtomValue(uiAtom) 19 | 20 | return ( 21 | 25 | 36 | 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /components/StyledTextInput.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { omit } from 'lodash-es' 3 | import { forwardRef } from 'react' 4 | import { TextInput, TextInputProps, TextStyle } from 'react-native' 5 | 6 | import { uiAtom } from '@/jotai/uiAtom' 7 | import tw from '@/utils/tw' 8 | 9 | const StyledTextInput = forwardRef< 10 | TextInput, 11 | TextInputProps & { size?: 'default' | 'large' } 12 | >(({ size, ...props }, ref) => { 13 | const { colors, fontSize } = useAtomValue(uiAtom) 14 | return ( 15 | 32 | ) 33 | }) 34 | 35 | export default StyledTextInput 36 | -------------------------------------------------------------------------------- /patches/react-native-image-zoom-viewer+3.0.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/react-native-image-zoom-viewer/.DS_Store b/node_modules/react-native-image-zoom-viewer/.DS_Store 2 | new file mode 100644 3 | index 0000000..3649ac7 4 | Binary files /dev/null and b/node_modules/react-native-image-zoom-viewer/.DS_Store differ 5 | diff --git a/node_modules/react-native-image-zoom-viewer/built/image-viewer.component.js b/node_modules/react-native-image-zoom-viewer/built/image-viewer.component.js 6 | index 3ca5e2b..445aada 100644 7 | --- a/node_modules/react-native-image-zoom-viewer/built/image-viewer.component.js 8 | +++ b/node_modules/react-native-image-zoom-viewer/built/image-viewer.component.js 9 | @@ -554,7 +554,7 @@ var ImageViewer = /** @class */ (function (_super) { 10 | {this.getMenu()} 11 | ); 12 | return ( 13 | - {childs} 14 | + {this.hasLayout && childs} 15 | ); 16 | }; 17 | ImageViewer.defaultProps = new image_viewer_type_1.Props(); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 liaoliao666 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /utils/useStatusBarStyle.ts: -------------------------------------------------------------------------------- 1 | import { useFocusEffect } from '@react-navigation/native' 2 | import { StatusBarStyle, setStatusBarStyle } from 'expo-status-bar' 3 | import { AppState, Keyboard } from 'react-native' 4 | 5 | import { store } from '@/jotai/store' 6 | import { colorSchemeAtom } from '@/jotai/themeAtom' 7 | 8 | let currentStatusBarStyle: StatusBarStyle 9 | 10 | function updateStatusBarStyle() { 11 | const nextStatusBarStyle = 12 | currentStatusBarStyle === 'auto' 13 | ? store.get(colorSchemeAtom) === 'dark' 14 | ? 'light' 15 | : 'dark' 16 | : currentStatusBarStyle 17 | 18 | setStatusBarStyle(nextStatusBarStyle) 19 | } 20 | 21 | export function useStatusBarStyle(statusBarStyle: StatusBarStyle) { 22 | useFocusEffect(() => { 23 | currentStatusBarStyle = statusBarStyle 24 | updateStatusBarStyle() 25 | }) 26 | } 27 | 28 | // updateStatusBarStyle when window on focus 29 | AppState.addEventListener('change', status => { 30 | if (status === 'active') { 31 | updateStatusBarStyle() 32 | } 33 | }) 34 | 35 | Keyboard.addListener('keyboardDidHide', () => { 36 | updateStatusBarStyle() 37 | }) 38 | -------------------------------------------------------------------------------- /screens/SelectableTextScreen.tsx: -------------------------------------------------------------------------------- 1 | import { RouteProp, useRoute } from '@react-navigation/native' 2 | import { useAtomValue } from 'jotai' 3 | import { ScrollView, View } from 'react-native' 4 | import { SafeAreaView } from 'react-native-safe-area-context' 5 | 6 | import Html from '@/components/Html' 7 | import NavBar from '@/components/NavBar' 8 | import { uiAtom } from '@/jotai/uiAtom' 9 | import { RootStackParamList } from '@/types' 10 | import tw from '@/utils/tw' 11 | 12 | export default function SelectableTextScreen() { 13 | const { 14 | params: { html }, 15 | } = useRoute>() 16 | 17 | const { colors } = useAtomValue(uiAtom) 18 | 19 | return ( 20 | 21 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /patches/react-native-render-html+6.3.4.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/react-native-render-html/src/render/RenderRegistry.ts b/node_modules/react-native-render-html/src/render/RenderRegistry.ts 2 | index aea3942..0ed85df 100644 3 | --- a/node_modules/react-native-render-html/src/render/RenderRegistry.ts 4 | +++ b/node_modules/react-native-render-html/src/render/RenderRegistry.ts 5 | @@ -28,7 +28,7 @@ export interface RendererConfig { 6 | export default class RenderRegistry { 7 | constructor( 8 | customRenderers: CustomTagRendererRecord = {}, 9 | - elementModels: HTMLElementModelRecord 10 | + elementModels: HTMLElementModelRecord, 11 | ) { 12 | this.customRenderers = customRenderers; 13 | this.elementModels = elementModels; 14 | @@ -58,6 +58,13 @@ export default class RenderRegistry { 15 | } 16 | return renderer as any; 17 | } 18 | + if ( 19 | + (tnode.type === 'text' || tnode.type === 'phrasing') 20 | + && this.customRenderers._TEXT_ 21 | + && !internalTextRenderers[tnode.tagName!] && !internalRenderers[tnode.tagName!] 22 | + ) { 23 | + return this.customRenderers._TEXT_ as any; 24 | + } 25 | return null; 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | ios 13 | android 14 | 15 | # macOS 16 | .DS_Store 17 | 18 | # @generated expo-cli sync-b25d41054229aa64f1468014f243adfae8268af2 19 | # The following patterns were generated by expo-cli 20 | 21 | # OSX 22 | # 23 | .DS_Store 24 | 25 | # Xcode 26 | # 27 | build/ 28 | *.pbxuser 29 | !default.pbxuser 30 | *.mode1v3 31 | !default.mode1v3 32 | *.mode2v3 33 | !default.mode2v3 34 | *.perspectivev3 35 | !default.perspectivev3 36 | xcuserdata 37 | *.xccheckout 38 | *.moved-aside 39 | DerivedData 40 | *.hmap 41 | *.ipa 42 | *.apk 43 | *.xcuserstate 44 | project.xcworkspace 45 | 46 | # Android/IntelliJ 47 | # 48 | build/ 49 | .idea 50 | .gradle 51 | local.properties 52 | *.iml 53 | *.hprof 54 | .cxx/ 55 | 56 | # node.js 57 | # 58 | node_modules/ 59 | npm-debug.log 60 | yarn-error.log 61 | 62 | # BUCK 63 | buck-out/ 64 | \.buckd/ 65 | *.keystore 66 | !debug.keystore 67 | 68 | # Bundle artifacts 69 | *.jsbundle 70 | 71 | # CocoaPods 72 | /ios/Pods/ 73 | 74 | # Expo 75 | .expo/ 76 | web-build/ 77 | dist/ 78 | 79 | # @end expo-cli 80 | 81 | # local env files 82 | .env*.local 83 | -------------------------------------------------------------------------------- /navigation/navigationRef.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | StackActions, 3 | createNavigationContainerRef, 4 | } from '@react-navigation/native' 5 | import { NativeStackNavigationProp } from '@react-navigation/native-stack' 6 | import { noop } from 'lodash-es' 7 | 8 | import { RootStackParamList } from '../types' 9 | 10 | export const navigationRef = createNavigationContainerRef() 11 | 12 | export const navigation = new Proxy( 13 | {}, 14 | { 15 | get: (_, key) => { 16 | if (!navigationRef.isReady()) return noop 17 | 18 | try { 19 | if (key in navigationRef) { 20 | return (navigationRef as any)[key] 21 | } 22 | 23 | return (...args: any[]) => { 24 | navigationRef.dispatch((StackActions as any)[key](...args)) 25 | } 26 | } catch (error) { 27 | throw new Error(`Invalid navigation key: ${key as string}`) 28 | } 29 | }, 30 | } 31 | ) as unknown as NativeStackNavigationProp< 32 | RootStackParamList, 33 | keyof RootStackParamList, 34 | undefined 35 | > 36 | 37 | export function getCurrentRouteName() { 38 | return navigationRef.getCurrentRoute()?.name as 39 | | keyof RootStackParamList 40 | | undefined 41 | } 42 | -------------------------------------------------------------------------------- /jotai/homeTabsAtom.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | 3 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 4 | 5 | export const RECENT_TAB_KEY = 'recent' 6 | export const XNA_KEY = 'xna' 7 | 8 | export type HomeTab = { 9 | title: string 10 | key: string 11 | type: 'tab' | 'node' | typeof RECENT_TAB_KEY | typeof XNA_KEY 12 | } 13 | 14 | export const allTabs = [ 15 | { title: '最近', key: RECENT_TAB_KEY, type: RECENT_TAB_KEY }, 16 | { title: '最热', key: 'hot' }, 17 | { title: '技术', key: 'tech' }, 18 | { title: '创意', key: 'creative' }, 19 | { title: '好玩', key: 'play' }, 20 | { title: 'Apple', key: 'apple' }, 21 | { title: '酷工作', key: 'jobs' }, 22 | { title: '交易', key: 'deals' }, 23 | { title: '城市', key: 'city' }, 24 | { title: '问与答', key: 'qna' }, 25 | { title: '全部', key: 'all' }, 26 | { title: 'R2', key: 'r2' }, 27 | { title: 'VXNA', key: XNA_KEY, type: XNA_KEY }, 28 | { title: '节点', key: 'nodes' }, 29 | { title: '关注', key: 'members' }, 30 | { title: '刚更新', key: 'changes' }, 31 | ].map(item => ({ ...item, type: item.type ?? 'tab' })) as HomeTab[] 32 | 33 | export const homeTabsAtom = atomWithAsyncStorage('tabs', allTabs) 34 | 35 | export const homeTabIndexAtom = atom(0) 36 | -------------------------------------------------------------------------------- /components/StyledImage/index.tsx: -------------------------------------------------------------------------------- 1 | import { ImageSource } from 'expo-image' 2 | import { isArray, isObject, isString } from 'lodash-es' 3 | import { memo } from 'react' 4 | 5 | import { isSvgURL, resolveURL } from '@/utils/url' 6 | 7 | import { BaseImageProps as StyledImageProps } from './BaseImage' 8 | import { BaseImage } from './BaseImage' 9 | import Svg from './Svg' 10 | import { imageResults } from './helper' 11 | 12 | export { StyledImageProps, imageResults } 13 | 14 | function StyledImage({ source, ...props }: StyledImageProps) { 15 | const URI = isString(source) 16 | ? source 17 | : isImageSource(source) 18 | ? source.uri 19 | : undefined 20 | const resolvedURI = URI ? resolveURL(URI) : undefined 21 | 22 | if (isString(resolvedURI) && isSvgURL(resolvedURI)) { 23 | return 24 | } 25 | 26 | return ( 27 | 34 | ) 35 | } 36 | 37 | function isImageSource(source: any): source is ImageSource { 38 | return isObject(source) && !isArray(source) && isString((source as any).uri) 39 | } 40 | 41 | export default StyledImage 42 | -------------------------------------------------------------------------------- /components/DebouncedPressable.tsx: -------------------------------------------------------------------------------- 1 | import { debounce } from 'lodash-es' 2 | import { forwardRef, useRef } from 'react' 3 | import { Pressable } from 'react-native' 4 | 5 | import { invoke } from '@/utils/invoke' 6 | 7 | const debouncePress = debounce(invoke, 500, { 8 | leading: true, 9 | trailing: false, 10 | }) 11 | 12 | const DebouncedPressable: typeof Pressable = forwardRef((props, ref) => { 13 | const touchActivatePositionRef = useRef<{ pageX: number; pageY: number }>({ 14 | pageX: 0, 15 | pageY: 0, 16 | }) 17 | 18 | return ( 19 | { 23 | const { pageX, pageY } = ev.nativeEvent 24 | touchActivatePositionRef.current = { 25 | pageX, 26 | pageY, 27 | } 28 | props.onPressIn?.(ev) 29 | }} 30 | onPress={ev => { 31 | const { pageX, pageY } = ev.nativeEvent 32 | const absX = Math.abs(touchActivatePositionRef.current.pageX - pageX) 33 | const absY = Math.abs(touchActivatePositionRef.current.pageY - pageY) 34 | const dragged = absX > 2 || absY > 2 35 | 36 | if (!dragged) { 37 | debouncePress(() => props.onPress?.(ev)) 38 | } 39 | }} 40 | /> 41 | ) 42 | }) 43 | 44 | export default DebouncedPressable 45 | -------------------------------------------------------------------------------- /components/RefetchingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { ReactNode } from 'react' 3 | import { View } from 'react-native' 4 | import Animated, { FadeIn, FadeOut } from 'react-native-reanimated' 5 | 6 | import { uiAtom } from '@/jotai/uiAtom' 7 | import tw from '@/utils/tw' 8 | 9 | import StyledActivityIndicator from './StyledActivityIndicator' 10 | 11 | export default function RefetchingIndicator({ 12 | children, 13 | progressViewOffset = 0, 14 | isRefetching, 15 | }: { 16 | children: ReactNode 17 | progressViewOffset?: number 18 | isRefetching: boolean 19 | }) { 20 | const { colors } = useAtomValue(uiAtom) 21 | return ( 22 | 23 | {children} 24 | 25 | {isRefetching && ( 26 | 34 | 37 | 38 | 39 | 40 | )} 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /components/Separator.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { Children, Fragment } from 'react' 3 | import { Text, View, ViewStyle } from 'react-native' 4 | 5 | import { uiAtom } from '@/jotai/uiAtom' 6 | import tw from '@/utils/tw' 7 | 8 | export type SeparatorProps = { 9 | children?: React.ReactNode 10 | style?: ViewStyle 11 | } 12 | 13 | export function DotSeparator() { 14 | const { colors, fontSize } = useAtomValue(uiAtom) 15 | return ( 16 | 19 | · 20 | 21 | ) 22 | } 23 | 24 | export function LineSeparator() { 25 | const { colors } = useAtomValue(uiAtom) 26 | return 27 | } 28 | 29 | export default function Separator({ children, style }: SeparatorProps) { 30 | return ( 31 | 32 | {Children.map(children, (child, i) => { 33 | const isLast = i === Children.count(children) - 1 34 | 35 | return ( 36 | child != null && ( 37 | 38 | {child} 39 | {!isLast && } 40 | 41 | ) 42 | ) 43 | })} 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /components/UploadImageButton.tsx: -------------------------------------------------------------------------------- 1 | import { Feather } from '@expo/vector-icons' 2 | import Toast from 'react-native-toast-message' 3 | 4 | import { imgurConfigAtom } from '@/jotai/imgurConfigAtom' 5 | import { store } from '@/jotai/store' 6 | import { navigation } from '@/navigation/navigationRef' 7 | import { k } from '@/servicies' 8 | 9 | import StyledButton, { StyledButtonProps } from './StyledButton' 10 | 11 | export default function UploadImageButton({ 12 | onUploaded, 13 | ...styledBUttonProps 14 | }: StyledButtonProps & { 15 | onUploaded: (url: string) => void 16 | }) { 17 | const { mutateAsync, isPending } = k.other.uploadImage.useMutation() 18 | 19 | return ( 20 | { 23 | if (!store.get(imgurConfigAtom)?.clientId) { 24 | navigation.navigate('ImgurConfig') 25 | return 26 | } 27 | 28 | if (isPending) return 29 | try { 30 | onUploaded(await mutateAsync()) 31 | } catch (error) { 32 | Toast.show({ 33 | type: 'error', 34 | text1: error instanceof Error ? error.message : '上传图片失败', 35 | }) 36 | } 37 | }} 38 | icon={} 39 | > 40 | {isPending ? '上传中' : '图片'} 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /components/Money.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { isEmpty } from 'lodash-es' 3 | import { Text, View, ViewStyle } from 'react-native' 4 | 5 | import { baseUrlAtom } from '@/jotai/baseUrlAtom' 6 | import { uiAtom } from '@/jotai/uiAtom' 7 | import tw from '@/utils/tw' 8 | 9 | import StyledImage from './StyledImage' 10 | 11 | export default function Money({ 12 | style, 13 | gold, 14 | silver, 15 | bronze, 16 | }: { 17 | style?: ViewStyle 18 | gold?: number 19 | silver?: number 20 | bronze?: number 21 | }) { 22 | const moneyOptions = [ 23 | { key: `gold`, value: gold }, 24 | { key: `silver`, value: silver }, 25 | { key: `bronze`, value: bronze }, 26 | ].filter(o => !!o.value) 27 | 28 | const { colors, fontSize } = useAtomValue(uiAtom) 29 | const baseURL = useAtomValue(baseUrlAtom) 30 | 31 | if (isEmpty(moneyOptions)) return null 32 | return ( 33 | 34 | {moneyOptions.map(o => ( 35 | 36 | 37 | {o.value} 38 | 39 | 43 | 44 | ))} 45 | 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /utils/cookie.ts: -------------------------------------------------------------------------------- 1 | // import CookieManager from '@react-native-cookies/cookies' 2 | import { isArray, noop } from 'lodash-es' 3 | 4 | import { isExpoGo } from './isExpoGo' 5 | import { sleep } from './sleep' 6 | import { getBaseURL } from './url' 7 | 8 | const RCTNetworking = 9 | require(`react-native/Libraries/Network/RCTNetworking`).default 10 | 11 | let CookieManager = { 12 | clearAll: noop, 13 | setFromResponse: noop, 14 | get: noop, 15 | } 16 | 17 | if (!isExpoGo) { 18 | CookieManager = require('@react-native-cookies/cookies') 19 | } 20 | 21 | export function clearCookie() { 22 | return Promise.race([ 23 | Promise.all([ 24 | new Promise(ok => RCTNetworking.clearCookies(ok)), 25 | CookieManager.clearAll(), 26 | CookieManager.clearAll(true), 27 | ]), 28 | sleep(300), 29 | ]).catch(noop) 30 | } 31 | 32 | export function setCookie(cookies: string[] | string) { 33 | return Promise.race([ 34 | CookieManager.setFromResponse( 35 | getBaseURL(), 36 | isArray(cookies) ? cookies.join(';') : cookies 37 | ), 38 | sleep(300), 39 | ]).catch(noop) 40 | } 41 | 42 | export async function getCookie(): Promise { 43 | return Object.entries( 44 | ((await Promise.race([ 45 | CookieManager.get(getBaseURL()), 46 | sleep(300), 47 | ])) as any) || {} 48 | ) 49 | .map(([key, { value }]: any) => `${key}=${value}`) 50 | .join(';') 51 | } 52 | -------------------------------------------------------------------------------- /components/Html/IFrameRenderer.tsx: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio' 2 | import { useContext, useMemo } from 'react' 3 | import { View } from 'react-native' 4 | import { CustomBlockRenderer } from 'react-native-render-html' 5 | import YoutubePlayer, { YoutubeIframeProps } from 'react-native-youtube-iframe' 6 | 7 | import tw from '@/utils/tw' 8 | import { useScreenWidth } from '@/utils/useScreenWidth' 9 | 10 | import { HtmlContext } from './HtmlContext' 11 | 12 | const aspectRatio = 1.778523489932886 13 | 14 | const webViewProps: YoutubeIframeProps['webViewProps'] = { 15 | androidLayerType: 'software', 16 | } 17 | 18 | const IFrameRenderer: CustomBlockRenderer = ({ tnode }) => { 19 | const { paddingX } = useContext(HtmlContext) 20 | const width = useScreenWidth() - paddingX 21 | const height = width / aspectRatio 22 | 23 | const videoId = useMemo(() => { 24 | const $ = load(tnode.domNode as unknown as string) 25 | const src = $('iframe').attr('src') 26 | return src?.includes('www.youtube.com') 27 | ? src.match(/\/(\w+)$/)?.[1] 28 | : undefined 29 | }, [tnode.domNode]) 30 | 31 | if (!videoId) return null 32 | 33 | return ( 34 | 35 | 41 | 42 | ) 43 | } 44 | 45 | export default IFrameRenderer 46 | -------------------------------------------------------------------------------- /terms-and-conditions_zh.md: -------------------------------------------------------------------------------- 1 | **条款和条件** 2 | 3 | 下载或使用该应用程序后,这些条款将自动适用于您——因此您应确保在使用该应用程序之前仔细阅读它们。 您不得以任何方式复制或修改应用程序、应用程序的任何部分或我们的商标。 您不得尝试提取应用程序的源代码,也不应尝试将应用程序翻译成其他语言或制作衍生版本。 该应用程序本身,以及与之相关的所有商标、版权、数据库权利和其他知识产权,仍属于 XuanLiao。 4 | 5 | XuanLiao 致力于确保该应用程序尽可能有用和高效。 因此,我们保留随时以任何理由更改应用程序或对其服务收费的权利。 在没有向您明确说明您所支付的费用的情况下,我们绝不会向您收取应用程序或其服务的费用。 6 | 7 | V2Fun 应用程序存储和处理您提供给我们的个人数据,以提供我的服务。 您有责任确保手机和应用程序的访问安全。 因此,我们建议您不要越狱或对手机进行 Root,这是消除软件限制和设备官方操作系统强加的限制的过程。 它可能使您的手机容易受到恶意软件/病毒/恶意程序的攻击,损害您手机的安全功能,并且可能意味着 V2Fun 应用程序无法正常运行或根本无法运行。 8 | 9 | 您应该知道,有些事情 XuanLiao 不承担任何责任。 该应用程序的某些功能需要该应用程序具有有效的互联网连接。 连接可以是 Wi-Fi 或由您的移动网络提供商提供,但如果您无法访问 Wi-Fi,并且您没有您的任何设备,XuanLiao 不对应用程序无法正常运行负责 剩余数据余量。 10 | 11 | 如果您在有 Wi-Fi 的区域之外使用该应用程序,您应该记住,与您的移动网络提供商签订的协议条款仍然适用。 因此,您的移动提供商可能会向您收取访问应用程序时连接期间的数据费用或其他第三方费用。 在使用该应用程序时,如果您在本国领土(即地区或国家/地区)之外使用该应用程序且未关闭数据漫游,则您将承担任何此类费用,包括漫游数据费用。 如果您不是您正在使用该应用程序的设备的账单付款人,请注意,我们假设您已获得账单付款人的使用该应用程序的许可。 12 | 13 | 同样,XuanLiao 不能总是对您使用该应用程序的方式负责,即您需要确保您的设备保持充电状态 - 如果电池电量耗尽并且您无法打开它以使用该服务,XuanLiao 不能承担责任。 14 | 15 | 关于 XuanLiao 对您使用该应用程序的责任,当您使用该应用程序时,请务必记住,尽管我们努力确保其始终更新和正确,但我们确实依赖第三方 向我们提供信息,以便我们可以将其提供给您。 对于您因完全依赖应用程序的此功能而遭受的任何直接或间接损失,XuanLiao 不承担任何责任。 16 | 17 | 在某些时候,我们可能希望更新应用程序。 该应用程序目前可在 Android 和 iOS 上使用——这两个系统(以及我们决定将该应用程序的可用性扩展到的任何其他系统)的要求可能会发生变化,如果您想继续使用,则需要下载更新 使用该应用程序。 XuanLiao 不承诺它会始终更新应用程序以使其与您相关和/或与您在设备上安装的 Android 和 iOS 版本一起使用。 但是,您承诺始终接受向您提供的应用程序更新,我们也可能希望停止提供该应用程序,并可能随时终止使用它,而无需向您发出终止通知。 除非我们另行通知您,否则一旦终止,(a) 这些条款授予您的权利和许可将终止; (b) 您必须停止使用该应用程序,并且(如果需要)将其从您的设备中删除。 18 | 19 | **本条款和条件的变更** 20 | 21 | 我可能会不时更新我们的条款和条件。 因此,建议您定期查看此页面以了解任何更改。 我将通过在此页面上发布新的条款和条件来通知您任何更改。 22 | 23 | 这些条款和条件自 2022-12-17 起生效 24 | -------------------------------------------------------------------------------- /components/StyledImage/Svg.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { View } from 'react-native' 3 | import { SvgXml, UriProps } from 'react-native-svg' 4 | 5 | import { uiAtom } from '@/jotai/uiAtom' 6 | import { k } from '@/servicies' 7 | import { hasSize } from '@/utils/hasSize' 8 | import tw from '@/utils/tw' 9 | 10 | import BrokenImage from './BrokenImage' 11 | import { computeOptimalDispalySize } from './helper' 12 | 13 | export default function Svg({ 14 | uri, 15 | style, 16 | containerWidth, 17 | ...props 18 | }: UriProps & { containerWidth?: number }) { 19 | const { colors } = useAtomValue(uiAtom) 20 | const hasPassedSize = hasSize(style) 21 | 22 | const svgQuery = k.other.svgXml.useQuery({ 23 | variables: uri!, 24 | }) 25 | 26 | if (svgQuery.isPending) { 27 | return ( 28 | 39 | ) 40 | } 41 | 42 | if (!svgQuery.data) { 43 | return 44 | } 45 | 46 | return ( 47 | 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /utils/useNavigationBar.ts: -------------------------------------------------------------------------------- 1 | import * as NavigationBar from 'expo-navigation-bar' 2 | import { useAtomValue } from 'jotai' 3 | import { useLayoutEffect } from 'react' 4 | import { AppState } from 'react-native' 5 | 6 | import { colorSchemeAtom } from '@/jotai/themeAtom' 7 | import { uiAtom } from '@/jotai/uiAtom' 8 | 9 | import { sleep } from './sleep' 10 | 11 | // https://github.com/liaoliao666/v2ex/issues/92 12 | export function useNavigationBar(readyAndroid: boolean) { 13 | const colorScheme = useAtomValue(colorSchemeAtom) 14 | const { colors } = useAtomValue(uiAtom) 15 | 16 | useLayoutEffect(() => { 17 | if (!readyAndroid) return 18 | 19 | const change = () => { 20 | // https://github.com/facebook/react-native/issues/38152#issuecomment-1649452526 21 | NavigationBar.setPositionAsync('relative') 22 | NavigationBar.setBorderColorAsync(`transparent`) 23 | NavigationBar.setBackgroundColorAsync(colors.base100) 24 | NavigationBar.setButtonStyleAsync( 25 | colorScheme === 'dark' ? 'light' : 'dark' 26 | ) 27 | } 28 | change() 29 | 30 | const handleChange = async () => { 31 | const changed = 32 | colors.base100 !== (await NavigationBar.getBackgroundColorAsync()) 33 | if (changed) { 34 | sleep(100).then(change) 35 | } 36 | } 37 | 38 | const l1 = AppState.addEventListener('change', handleChange) 39 | const l2 = AppState.addEventListener('focus', handleChange) 40 | 41 | return () => { 42 | l1.remove() 43 | l2.remove() 44 | } 45 | }, [colors.base100, colorScheme, readyAndroid]) 46 | } 47 | -------------------------------------------------------------------------------- /utils/useQueryData.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DataTag, 3 | QueryKey, 4 | hashKey, 5 | notifyManager, 6 | } from '@tanstack/react-query' 7 | import { useCallback, useSyncExternalStore } from 'react' 8 | 9 | import { queryClient } from './query' 10 | 11 | type Listener = () => void 12 | const queryListeners = new Map>() 13 | const queryCache = queryClient.getQueryCache() 14 | 15 | queryCache.subscribe(({ query: { queryHash }, type }) => { 16 | if (queryListeners.has(queryHash) && !type.startsWith(`observer`)) { 17 | const listeners = queryListeners.get(queryHash)! 18 | listeners.forEach(l => l()) 19 | } 20 | }) 21 | 22 | const subscribeQuery = (queryHash: string, listener: Listener) => { 23 | if (!queryListeners.has(queryHash)) { 24 | queryListeners.set(queryHash, new Set()) 25 | } 26 | const listeners = queryListeners.get(queryHash)! 27 | 28 | listeners.add(listener) 29 | return () => listeners.delete(listener) 30 | } 31 | 32 | export const useQueryData = ( 33 | queryKey: DataTag, 34 | select?: (data?: TQueryData) => TData 35 | ): TData => { 36 | const queryHash = hashKey(queryKey) 37 | const getSnapshot = () => { 38 | const data = queryCache.get(queryHash)?.state.data as TQueryData 39 | return (select ? select(data) : data) as TData 40 | } 41 | 42 | return useSyncExternalStore( 43 | useCallback( 44 | onStoreChange => 45 | subscribeQuery(queryHash, notifyManager.batchCalls(onStoreChange)), 46 | [queryHash] 47 | ), 48 | getSnapshot, 49 | getSnapshot 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /components/RadioButtonGroup.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { Pressable, Text, View, ViewStyle } from 'react-native' 3 | 4 | import { uiAtom } from '@/jotai/uiAtom' 5 | import tw from '@/utils/tw' 6 | 7 | export default function RadioButtonGroup< 8 | T extends number | string | void | null 9 | >({ 10 | value, 11 | onChange, 12 | options, 13 | style, 14 | }: { 15 | value: T 16 | onChange: (value: T) => void 17 | options: { label: string; value: T }[] 18 | style?: ViewStyle 19 | }) { 20 | const { colors, fontSize } = useAtomValue(uiAtom) 21 | return ( 22 | 23 | 24 | {options.map(item => { 25 | const active = value === item.value 26 | 27 | return ( 28 | { 31 | if (item.value !== value) { 32 | onChange(item.value) 33 | } 34 | }} 35 | style={tw.style( 36 | `px-1.5 flex-row items-center rounded-lg`, 37 | active && `bg-[${colors.base100}]` 38 | )} 39 | > 40 | 46 | {item.label} 47 | 48 | 49 | ) 50 | })} 51 | 52 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /components/ListItem.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { ReactNode, cloneElement, isValidElement } from 'react' 3 | import { PressableProps, Text, View, ViewStyle } from 'react-native' 4 | 5 | import { uiAtom } from '@/jotai/uiAtom' 6 | import tw from '@/utils/tw' 7 | 8 | import DebouncedPressable from './DebouncedPressable' 9 | 10 | export interface ListItemProps { 11 | label?: string 12 | icon: ReactNode 13 | action?: ReactNode 14 | onPress?: PressableProps['onPress'] 15 | pressable?: boolean 16 | style?: ViewStyle 17 | } 18 | 19 | export default function ListItem({ 20 | label, 21 | icon, 22 | action, 23 | onPress, 24 | pressable = true, 25 | style, 26 | }: ListItemProps) { 27 | const { colors, fontSize } = useAtomValue(uiAtom) 28 | 29 | return ( 30 | 32 | tw.style( 33 | `px-4 h-[56px] flex-row items-center`, 34 | pressed && 35 | pressable && 36 | !!label && 37 | `bg-[${colors.foreground}] bg-opacity-10`, 38 | style 39 | ) 40 | } 41 | onPress={onPress} 42 | > 43 | {({ pressed }) => ( 44 | <> 45 | {!label && isValidElement(icon) 46 | ? cloneElement(icon as any, { pressed }) 47 | : icon} 48 | 49 | {!!label && ( 50 | 53 | {label} 54 | 55 | )} 56 | 57 | {action} 58 | 59 | )} 60 | 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /jotai/themeAtom.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { AppState, Appearance } from 'react-native' 3 | 4 | import tw from '@/utils/tw' 5 | 6 | import { store } from './store' 7 | import { atomWithAsyncStorage } from './utils/atomWithAsyncStorage' 8 | 9 | export type ThemeScheme = 'light' | 'dark' | 'system' 10 | 11 | const baseThemeAtom = atomWithAsyncStorage('theme', 'system') 12 | 13 | export const themeAtom = atom( 14 | get => get(baseThemeAtom), 15 | (get, set, update) => { 16 | const nextValue = 17 | typeof update === 'function' ? update(get(baseThemeAtom)) : update 18 | tw.setColorScheme(getColorScheme(nextValue)) 19 | set(baseThemeAtom, nextValue) 20 | } 21 | ) 22 | 23 | const forceUpdateColorSchemeAtom = atom(0) 24 | 25 | export const colorSchemeAtom = atom<'light' | 'dark'>(get => { 26 | get(forceUpdateColorSchemeAtom) 27 | return getColorScheme(get(baseThemeAtom)) 28 | }) 29 | 30 | function getColorScheme(theme: ThemeScheme) { 31 | return theme === 'system' ? Appearance.getColorScheme() || 'light' : theme 32 | } 33 | 34 | function handleColorSchemeChange() { 35 | const systemColorScheme = Appearance.getColorScheme() 36 | const theme = store.get(themeAtom) 37 | const colorScheme = store.get(colorSchemeAtom) 38 | 39 | if ( 40 | AppState.currentState !== 'background' && 41 | theme === 'system' && 42 | colorScheme !== systemColorScheme 43 | ) { 44 | tw.setColorScheme(getColorScheme(store.get(baseThemeAtom)!)) 45 | store.set(forceUpdateColorSchemeAtom, prev => ++prev) 46 | } 47 | } 48 | 49 | Appearance.addChangeListener(handleColorSchemeChange) 50 | AppState.addEventListener('change', handleColorSchemeChange) 51 | -------------------------------------------------------------------------------- /privacy-policy_zh.md: -------------------------------------------------------------------------------- 1 | **隐私政策** 2 | 3 | XuanLiao 将 V2Fun 应用程序构建为免费应用程序。 本服务由 XuanLiao 免费提供,旨在按原样使用。 4 | 5 | 如果有人决定使用我的服务,此页面用于告知访问者有关我收集、使用和披露个人信息的政策。 6 | 7 | 如果您选择使用我的服务,则表示您同意收集和使用与本政策相关的信息。 我收集的个人信息用于提供和改进服务。 除本隐私政策中所述外,我不会使用或与任何人分享您的信息。 8 | 9 | 本隐私政策中使用的术语与我们的条款和条件具有相同的含义,除非本隐私政策中另有定义,否则可在 V2Fun 访问这些条款和条件。 10 | 11 | **信息收集和使用** 12 | 13 | 为了获得更好的体验,在使用我们的服务时,我可能会要求您向我们提供某些个人身份信息。 我请求的信息将保留在您的设备上,我不会以任何方式收集。 14 | 15 | **日志数据** 16 | 17 | 我想通知您,每当您使用我的服务时,如果应用程序出现错误,我会(通过第三方产品)在您的手机上收集名为日志数据的数据和信息。 此日志数据可能包括您的设备互联网协议(“IP”)地址、设备名称、操作系统版本、使用我的服务时应用程序的配置、您使用服务的时间和日期以及其他统计信息等信息 . 18 | 19 | **饼干** 20 | 21 | Cookie 是包含少量数据的文件,通常用作匿名唯一标识符。 这些信息会从您访问的网站发送到您的浏览器,并存储在您设备的内存中。 22 | 23 | 本服务不明确使用这些“cookies”。 但是,该应用程序可能会使用第三方代码和使用“cookies”的库来收集信息并改进其服务。 您可以选择接受或拒绝这些 cookie,并知道何时将 cookie 发送到您的设备。 如果您选择拒绝我们的 cookie,您可能无法使用本服务的某些部分。 24 | 25 | **服务供应商** 26 | 27 | 由于以下原因,我可能会雇用第三方公司和个人: 28 | 29 | - 促进我们的服务; 30 | - 代表我们提供服务; 31 | - 执行与服务相关的服务; 要么 32 | - 协助我们分析我们的服务是如何被使用的。 33 | 34 | 我想通知此服务的用户,这些第三方可以访问他们的个人信息。 原因是代表我们执行分配给他们的任务。 但是,他们有义务不为任何其他目的披露或使用这些信息。 35 | 36 | **安全** 37 | 38 | 我重视您向我们提供您的个人信息的信任,因此我们正在努力使用商业上可接受的方式来保护它。 但请记住,没有一种网络传输方式或电子存储方式是 100% 安全可靠的,我无法保证其绝对安全。 39 | 40 | **其他网站的链接** 41 | 42 | 本服务可能包含其他网站的链接。 如果您单击第三方链接,您将被定向到该站点。 请注意,这些外部网站不是由我运营的。 因此,我强烈建议您查看这些网站的隐私政策。 我无法控制任何第三方网站或服务的内容、隐私政策或做法,也不承担任何责任。 43 | 44 | **儿童隐私** 45 | 46 | 我不会故意收集儿童的个人身份信息。 我鼓励所有儿童永远不要通过应用程序和/或服务提交任何个人身份信息。 我鼓励父母和法定监护人监控他们孩子的互联网使用情况,并通过指示他们的孩子在未经他们许可的情况下永远不要通过应用程序和/或服务提供个人身份信息来帮助执行本政策。 如果您有理由相信儿童通过应用程序和/或服务向我们提供了个人身份信息,请联系我们。 您还必须年满 16 岁才能同意在您所在的国家/地区处理您的个人身份信息(在某些国家/地区,我们可能允许您的父母或监护人代表您这样做)。 47 | 48 | **本隐私政策的变更** 49 | 50 | 我可能会不时更新我们的隐私政策。 因此,建议您定期查看此页面以了解任何更改。 我将通过在此页面上发布新的隐私政策来通知您任何更改。 51 | 52 | 本政策自 2022-12-17 起生效 53 | -------------------------------------------------------------------------------- /components/StyledBlurView.tsx: -------------------------------------------------------------------------------- 1 | import { transparentize } from 'color2k' 2 | import { BlurView, BlurViewProps } from 'expo-blur' 3 | import { useAtomValue } from 'jotai' 4 | import { Platform, View } from 'react-native' 5 | 6 | import { colorSchemeAtom } from '@/jotai/themeAtom' 7 | import { 8 | defaultColors, 9 | formatColor, 10 | themeColorsMap, 11 | uiAtom, 12 | } from '@/jotai/uiAtom' 13 | import tw from '@/utils/tw' 14 | 15 | export const supportsBlurviewColors = [ 16 | defaultColors.base100.dark, 17 | defaultColors.base100.light, 18 | themeColorsMap.business.base100, 19 | themeColorsMap.business.base100, 20 | themeColorsMap.forest.base100, 21 | themeColorsMap.black.base100, 22 | themeColorsMap.synthwave.base100, 23 | themeColorsMap.night.base100, 24 | themeColorsMap.coffee.base100, 25 | themeColorsMap.sunset.base100, 26 | themeColorsMap.lemonade.base100, 27 | themeColorsMap.autumn.base100, 28 | themeColorsMap.acid.base100, 29 | themeColorsMap.cupcake.base100, 30 | ] 31 | 32 | export default function StyledBlurView(props: BlurViewProps) { 33 | const colorScheme = useAtomValue(colorSchemeAtom) 34 | const { colors } = useAtomValue(uiAtom) 35 | 36 | if ( 37 | Platform.OS === 'ios' && 38 | supportsBlurviewColors.includes(colors.base100) 39 | ) { 40 | return ( 41 | 49 | ) 50 | } 51 | 52 | return ( 53 | 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /components/Badge.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import React from 'react' 3 | import { Text, View, ViewStyle } from 'react-native' 4 | 5 | import { uiAtom } from '@/jotai/uiAtom' 6 | import tw from '@/utils/tw' 7 | 8 | const dot = 9 | 10 | const dotSize = 10 11 | 12 | export type BadgeProps = { 13 | content?: React.ReactNode | typeof dot 14 | color?: string 15 | bordered?: boolean 16 | children?: React.ReactNode 17 | wrapperStyle?: ViewStyle 18 | right?: number 19 | top?: number 20 | } 21 | 22 | export default function Badge(props: BadgeProps) { 23 | const { 24 | content = dot, 25 | color = '#ff411c', 26 | children, 27 | top = -dotSize / 2, 28 | right = -dotSize / 2, 29 | wrapperStyle, 30 | } = props 31 | 32 | const isDot = content === dot 33 | 34 | const { colors } = useAtomValue(uiAtom) 35 | 36 | const element = content ? ( 37 | 50 | {!isDot && ( 51 | 54 | {content} 55 | 56 | )} 57 | 58 | ) : null 59 | 60 | return children ? ( 61 | 62 | {children} 63 | {element} 64 | 65 | ) : ( 66 | element 67 | ) 68 | } 69 | 70 | Badge.dot = dot 71 | -------------------------------------------------------------------------------- /components/DragableItem.tsx: -------------------------------------------------------------------------------- 1 | import { MaterialCommunityIcons } from '@expo/vector-icons' 2 | import { useAtomValue } from 'jotai' 3 | import { Pressable, Text, View } from 'react-native' 4 | 5 | import { uiAtom } from '@/jotai/uiAtom' 6 | import tw from '@/utils/tw' 7 | 8 | export default function DragableItem({ 9 | children, 10 | itemWidth, 11 | itemHeight, 12 | iconName, 13 | onIconPress, 14 | }: { 15 | children: string 16 | iconName?: React.ComponentProps['name'] 17 | onIconPress?: () => void 18 | itemWidth: number 19 | itemHeight: number 20 | }) { 21 | const { colors, fontSize } = useAtomValue(uiAtom) 22 | 23 | return ( 24 | 27 | 36 | 4 ? 'middle' : undefined} 40 | > 41 | {children} 42 | 43 | 44 | {!!iconName && ( 45 | 52 | 57 | 58 | )} 59 | 60 | 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /components/AsyncStoragePersist.tsx: -------------------------------------------------------------------------------- 1 | import AsyncStorage from '@react-native-async-storage/async-storage' 2 | import { 3 | HydrationBoundary, 4 | dehydrate, 5 | useQueryClient, 6 | } from '@tanstack/react-query' 7 | import { ReactNode, useEffect } from 'react' 8 | import { suspend } from 'suspend-react' 9 | 10 | const CACHE_KEY = 'app-cache' 11 | 12 | export function AsyncStoragePersist({ children }: { children: ReactNode }) { 13 | const appCache = suspend( 14 | () => 15 | AsyncStorage.getItem(CACHE_KEY) 16 | .then(cache => JSON.parse(cache ?? 'null')) 17 | .catch(() => null), 18 | [CACHE_KEY] 19 | ) 20 | const queryClient = useQueryClient() 21 | 22 | useEffect(() => { 23 | let changed = false 24 | let running = false 25 | 26 | const unsubscribe = queryClient.getQueryCache().subscribe(({ type }) => { 27 | if (!type.startsWith(`observer`)) { 28 | changed = true 29 | } 30 | }) 31 | 32 | const timer = setInterval(async () => { 33 | if (changed && !running) { 34 | try { 35 | changed = false 36 | running = true 37 | await AsyncStorage.setItem( 38 | CACHE_KEY, 39 | JSON.stringify( 40 | dehydrate(queryClient, { 41 | shouldDehydrateQuery: query => 42 | query.state.status === 'success' && 43 | !query.isStaleByTime(1000 * 60 * 60 * 24), 44 | }) 45 | ) 46 | ) 47 | } catch { 48 | } finally { 49 | running = false 50 | } 51 | } 52 | }, 1000) 53 | 54 | return () => { 55 | unsubscribe() 56 | clearTimeout(timer) 57 | } 58 | }, [queryClient]) 59 | 60 | return {children} 61 | } 62 | -------------------------------------------------------------------------------- /components/Html/TextRenderer.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { createContext, useContext } from 'react' 3 | import { Platform, Text, TextInput } from 'react-native' 4 | import { 5 | CustomTextualRenderer, 6 | getNativePropsForTNode, 7 | } from 'react-native-render-html' 8 | 9 | import { uiAtom } from '@/jotai/uiAtom' 10 | 11 | import { HtmlContext } from './HtmlContext' 12 | 13 | const IsNestedTextContext = createContext(false) 14 | 15 | const resetTextInputStyle = { 16 | paddingTop: 0, 17 | marginTop: -3, 18 | paddingBottom: 3, 19 | } 20 | 21 | const TextRenderer: CustomTextualRenderer = props => { 22 | const { onSelectText, selectOnly } = useContext(HtmlContext) 23 | 24 | const { colors } = useAtomValue(uiAtom) 25 | 26 | let renderProps = getNativePropsForTNode(props) 27 | 28 | const isNestedText = useContext(IsNestedTextContext) 29 | 30 | let text = null 31 | 32 | if (isNestedText) { 33 | text = 34 | } else if (Platform.OS === 'ios' && selectOnly) { 35 | text = ( 36 | 42 | 43 | 44 | ) 45 | } else { 46 | if (Platform.OS === 'ios' && renderProps.selectable) { 47 | renderProps = { 48 | ...renderProps, 49 | selectable: false, 50 | onLongPress: onSelectText, 51 | suppressHighlighting: true, 52 | } 53 | } 54 | 55 | text = 56 | } 57 | 58 | return ( 59 | 60 | {text} 61 | 62 | ) 63 | } 64 | 65 | export default TextRenderer 66 | -------------------------------------------------------------------------------- /components/Html/ImageRenderer.tsx: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio' 2 | import { isObjectLike } from 'lodash-es' 3 | import { useContext, useMemo } from 'react' 4 | import { Pressable } from 'react-native' 5 | import { CustomBlockRenderer } from 'react-native-render-html' 6 | 7 | import { isSvgURL } from '@/utils/url' 8 | import { useScreenWidth } from '@/utils/useScreenWidth' 9 | 10 | import StyledImage from '../StyledImage' 11 | import { HtmlContext } from './HtmlContext' 12 | 13 | const ImageRenderer: CustomBlockRenderer = ({ tnode, style }) => { 14 | const { onPreview, paddingX } = useContext(HtmlContext) 15 | 16 | const url = useMemo(() => { 17 | const $ = load(tnode.domNode as unknown as string) 18 | return $('img').attr('src') 19 | }, [tnode.domNode]) 20 | 21 | const screenWidth = useScreenWidth() 22 | const containerWidth = isPlainContainer(tnode) 23 | ? screenWidth - paddingX 24 | : undefined 25 | 26 | if (url && isSvgURL(url)) 27 | return ( 28 | 33 | ) 34 | 35 | return ( 36 | { 38 | ev.stopPropagation() 39 | if (url) onPreview(url) 40 | }} 41 | > 42 | 49 | 50 | ) 51 | } 52 | 53 | const plainContainers = ['html', 'body', 'div', 'a', 'p', 'img'] 54 | 55 | function isPlainContainer(tnode: any, lever = 0): boolean { 56 | if (!isObjectLike(tnode) || lever >= 3) return true 57 | if (!plainContainers.includes(tnode.tagName)) return false 58 | return isPlainContainer(tnode.parent, lever + 1) 59 | } 60 | 61 | export default ImageRenderer 62 | -------------------------------------------------------------------------------- /components/CollapsibleTabView.tsx: -------------------------------------------------------------------------------- 1 | // TODO 2 | import { ReactNode, RefObject, createRef, useState } from 'react' 3 | import { Animated, ScrollView, ScrollViewProps, View } from 'react-native' 4 | import { 5 | Route, 6 | SceneRendererProps, 7 | TabBar, 8 | TabView, 9 | TabViewProps, 10 | } from 'react-native-tab-view' 11 | 12 | export interface CollapsibleTabView 13 | extends Omit, 'renderScene'> { 14 | renderHeader: () => ReactNode 15 | renderScene: ( 16 | sceneProps: SceneRendererProps & { 17 | route: T 18 | scrollViewProps: { 19 | ref: RefObject 20 | contentContainerStyle: ScrollViewProps 21 | } 22 | } 23 | ) => ReactNode 24 | } 25 | 26 | export function CollapsibleTabView({ 27 | renderTabBar = tabBarProps => , 28 | renderHeader, 29 | renderScene, 30 | navigationState, 31 | ...props 32 | }: CollapsibleTabView) { 33 | // const [headerHeight, setHeaderHeight] = useState(0) 34 | 35 | const [refs] = useState>>({}) 36 | 37 | return ( 38 | ( 42 | 43 | {renderHeader()} 44 | 45 | {renderTabBar(tabBarProps)} 46 | 47 | )} 48 | renderScene={sceneProps => { 49 | const scrollViewProps = { 50 | ref: 51 | refs[sceneProps.route.key] || 52 | (refs[sceneProps.route.key] = createRef()), 53 | contentContainerStyle: {}, 54 | } 55 | return renderScene({ 56 | scrollViewProps, 57 | ...sceneProps, 58 | }) 59 | }} 60 | /> 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # V2Fun 2 | 3 | > V2EX 好看的第三方客户端,原生 App,支持夜间模式。 4 | 5 | ## 预览 6 | 7 | ![Preview](https://files.catbox.moe/q6sy9n.gif) 8 | 9 | ## 下载 10 | 11 | - [iOS App Store](https://apps.apple.com/cn/app/v2fun/id1659591551?l=en) 12 | - [Android Apk](https://github.com/liaoliao666/v2ex/releases/latest) 13 | 14 | ## 本地运行 15 | 16 | [Expo 文档](https://docs.expo.dev/) 17 | 18 | ## URL Scheme 19 | 20 | | 页面 | URL Scheme | 例子 | 21 | | -------- | ------------------------ | ------------------------------------ | 22 | | 搜索 | `v2fun://search/:query?` | `v2fun://search/v2fun这款应用怎么样` | 23 | | 帖子 | `v2fun://topic/:id` | `v2fun://topic/904226` | 24 | | 用户 | `member/:username` | `v2fun://member/iliaoliao` | 25 | | 节点 | `node/:name` | `v2fun://node/apple` | 26 | 27 | ## Issues 28 | 29 | 希望做出贡献? [Good First Issue][good-first-issue] 30 | 31 | ### 🐛 Bugs 32 | 33 | 请针对错误、缺少文档或意外行为提出问题。 34 | 35 | [**See Bugs**][bugs] 36 | 37 | ### 💡 新功能建议 38 | 39 | 请提交问题以建议新功能。 通过添加对功能请求进行投票 40 | 一个 👍。 这有助于作者确定工作的优先级。 41 | 42 | [**See Feature Requests**][requests] 43 | 44 | ## 感谢 45 | 46 | - [V2HOT](https://www.v2ex.com/t/822020?utm_source=pipecraft.net) : 一个能看每天 V2EX 最热的网站,历史最热功能基于此实现,感谢 🙏 47 | - [SOV2EX](https://github.com/Bynil/sov2ex) : 一个便捷的 V2EX 站内搜索引擎,搜索功能基于此实现,感谢 🙏 48 | 49 | ## LICENSE 50 | 51 | MIT 52 | 53 | 54 | [bugs]: https://github.com/liaoliao666/v2ex/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+sort%3Acreated-desc+label%3Abug 55 | [requests]: https://github.com/liaoliao666/v2ex/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3Aenhancement 56 | [good-first-issue]: https://github.com/liaoliao666/v2ex/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3Aenhancement+label%3A%22good+first+issue%22 57 | 58 | -------------------------------------------------------------------------------- /components/StyledImage/helper.ts: -------------------------------------------------------------------------------- 1 | import { ViewStyle } from 'react-native' 2 | 3 | type ImageResult = 4 | | { 5 | width: number 6 | height: number 7 | isAnimated?: boolean 8 | mediaType: string | null 9 | } 10 | | 'error' 11 | | 'refetching' 12 | 13 | export const imageResults = new Map() 14 | 15 | export const MAX_IMAGE_HEIGHT = 510 16 | 17 | export const BROKEN_IMAGE_SIZE = 24 18 | 19 | export function computeOptimalDispalySize( 20 | containerWidth?: number, 21 | size?: ImageResult 22 | ): ViewStyle { 23 | if (size === 'refetching' || size === 'error') { 24 | return { 25 | width: BROKEN_IMAGE_SIZE, 26 | height: BROKEN_IMAGE_SIZE, 27 | } 28 | } 29 | 30 | // Display placeholder size if image size is not available 31 | if (!size) { 32 | return { 33 | aspectRatio: 1, 34 | width: containerWidth 35 | ? Math.min(MAX_IMAGE_HEIGHT, containerWidth) 36 | : `100%`, 37 | } 38 | } 39 | 40 | const { width, height } = size 41 | 42 | // Display mini image 43 | if (width <= 100 && height <= 100) { 44 | return { width, height } 45 | } 46 | 47 | const aspectRatio = width / height 48 | 49 | // Display auto fit image 50 | if (!containerWidth) { 51 | return { 52 | aspectRatio, 53 | width: `100%`, 54 | } 55 | } 56 | 57 | // Display small image 58 | if ( 59 | width <= Math.min(MAX_IMAGE_HEIGHT, containerWidth) && 60 | height <= MAX_IMAGE_HEIGHT 61 | ) { 62 | return { width, height } 63 | } 64 | 65 | // Display optimal size 66 | const actualWidth = Math.min(aspectRatio * MAX_IMAGE_HEIGHT, containerWidth) 67 | return actualWidth === containerWidth 68 | ? { 69 | aspectRatio, 70 | width: `100%`, 71 | } 72 | : { 73 | width: actualWidth, 74 | height: actualWidth / aspectRatio, 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /components/FormControl.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { ReactNode } from 'react' 3 | import { 4 | Controller, 5 | ControllerProps, 6 | FieldPath, 7 | FieldValues, 8 | } from 'react-hook-form' 9 | import { Text, View } from 'react-native' 10 | import { ViewStyle } from 'react-native' 11 | 12 | import { uiAtom } from '@/jotai/uiAtom' 13 | import tw from '@/utils/tw' 14 | 15 | interface FormControlProps< 16 | TFieldValues extends FieldValues = FieldValues, 17 | TName extends FieldPath = FieldPath 18 | > extends ControllerProps { 19 | label?: string 20 | style?: ViewStyle 21 | extra?: ReactNode 22 | } 23 | 24 | export default function FormControl< 25 | TFieldValues extends FieldValues = FieldValues, 26 | TName extends FieldPath = FieldPath 27 | >({ 28 | label, 29 | style, 30 | render, 31 | extra, 32 | ...rest 33 | }: FormControlProps) { 34 | const { colors, fontSize } = useAtomValue(uiAtom) 35 | 36 | return ( 37 | ( 40 | 41 | 42 | {!!label && ( 43 | 48 | {label} 49 | 50 | )} 51 | 52 | {extra} 53 | 54 | {render(props)} 55 | 56 | {!!props.fieldState.error?.message && ( 57 | 58 | {props.fieldState.error?.message} 59 | 60 | )} 61 | 62 | 63 | )} 64 | /> 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /servicies/node.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio' 2 | import { router } from 'react-query-kit' 3 | 4 | import { invoke } from '@/utils/invoke' 5 | import { removeUnnecessaryPages } from '@/utils/query' 6 | import { request } from '@/utils/request' 7 | import { getURLSearchParams } from '@/utils/url' 8 | 9 | import { getNextPageParam, parseLastPage, parseTopicItems } from './helper' 10 | import { Node, PageData, Topic } from './types' 11 | 12 | export const nodeRouter = router(`node`, { 13 | all: router.query({ 14 | fetcher: (_, { signal }): Promise => 15 | request.get(`/api/nodes/all.json`, { signal }).then(res => res.data), 16 | }), 17 | 18 | topics: router.infiniteQuery({ 19 | fetcher: async ( 20 | { name }: { name: string }, 21 | { pageParam, signal } 22 | ): Promise & { liked?: boolean; once?: string }> => { 23 | const { data } = await request.get(`/go/${name}?p=${pageParam}`, { 24 | responseType: 'text', 25 | signal, 26 | }) 27 | const $ = load(data) 28 | 29 | return { 30 | page: pageParam, 31 | last_page: parseLastPage($), 32 | list: parseTopicItems($, '#TopicsNode .cell'), 33 | ...invoke(() => { 34 | const url = $('.cell_ops a').attr('href') 35 | if (!url) return 36 | return { 37 | once: getURLSearchParams(url).once, 38 | liked: url.includes('unfavorite'), 39 | } 40 | }), 41 | } 42 | }, 43 | initialPageParam: 1, 44 | getNextPageParam, 45 | structuralSharing: false, 46 | use: [removeUnnecessaryPages], 47 | }), 48 | 49 | like: router.mutation({ 50 | mutationFn: ({ 51 | id, 52 | once, 53 | type, 54 | }: { 55 | id: number 56 | once: string 57 | type: 'unfavorite' | 'favorite' 58 | }) => 59 | request.get(`/${type}/node/${id}?once=${once}`, { 60 | responseType: 'text', 61 | }), 62 | }), 63 | }) 64 | -------------------------------------------------------------------------------- /types.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about using TypeScript with React Navigation: 3 | * https://reactnavigation.org/docs/typescript/ 4 | */ 5 | import { NativeStackScreenProps } from '@react-navigation/native-stack' 6 | 7 | import { Node, Topic } from './servicies/types' 8 | 9 | declare global { 10 | namespace ReactNavigation { 11 | interface RootParamList extends RootStackParamList {} 12 | } 13 | } 14 | 15 | declare module 'axios' { 16 | export interface AxiosRequestConfig { 17 | transformResponseScript?: string 18 | } 19 | } 20 | 21 | export type RootStackParamList = { 22 | Root: undefined 23 | Home: undefined 24 | SortTabs: undefined 25 | NotFound: undefined 26 | MyNodes: undefined 27 | MyTopics: undefined 28 | MyFollowing: undefined 29 | Notifications: undefined 30 | Search: { 31 | query?: string 32 | } 33 | SearchOptions: undefined 34 | SearchNode: { 35 | onPressNodeItem: (node: Node) => void 36 | } 37 | SearchReplyMember: { 38 | topicId: number 39 | onAtNames: (atNames: string) => void 40 | } 41 | Login: undefined 42 | TopicDetail: Partial & { hightlightReplyNo?: number; id: number } 43 | RelatedReplies: { 44 | replyId: number 45 | onReply: (username: string) => void 46 | topicId: number 47 | } 48 | NodeTopics: { 49 | name: string 50 | } 51 | MemberDetail: { 52 | username: string 53 | } 54 | WriteTopic: { 55 | topic?: Topic 56 | } 57 | NavNodes: undefined 58 | GItHubMD: { 59 | url: string 60 | title: string 61 | } 62 | WebSignin: undefined 63 | RecentTopic: undefined 64 | Setting: undefined 65 | Rank: undefined 66 | BlankList: undefined 67 | Webview: { 68 | url: string 69 | } 70 | ImgurConfig: undefined 71 | SelectableText: { 72 | html: string 73 | } 74 | HotestTopics: undefined 75 | ConfigureDomain: undefined 76 | CustomizeTheme: undefined 77 | } 78 | 79 | export type RootStackScreenProps = 80 | NativeStackScreenProps 81 | -------------------------------------------------------------------------------- /components/placeholder/TopicPlaceholder.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { View, ViewStyle } from 'react-native' 3 | 4 | import { uiAtom } from '@/jotai/uiAtom' 5 | import tw from '@/utils/tw' 6 | 7 | import { PlaceholderShape } from './PlaceholderShape' 8 | import { Placeholder } from './Placeholder' 9 | import { PlaceholderLine } from './PlaceholderLine' 10 | 11 | function AvatarPlaceholder() { 12 | const { colors } = useAtomValue(uiAtom) 13 | 14 | return ( 15 | 18 | ) 19 | } 20 | 21 | export function TopicItemPlaceholder({ hideAvatar }: { hideAvatar?: boolean }) { 22 | const { colors, fontSize } = useAtomValue(uiAtom) 23 | const fontMedium = tw.style(fontSize.medium) as { 24 | fontSize: number 25 | lineHeight: number 26 | } 27 | const mediumLineStyle = tw`h-[${fontMedium.fontSize}px] my-[${Math.floor( 28 | (fontMedium.lineHeight - fontMedium.fontSize) / 2 29 | )}px]` 30 | 31 | return ( 32 | 36 | 37 | 42 | 43 | 48 | 49 | 50 | ) 51 | } 52 | 53 | export default function TopicPlaceholder({ 54 | style, 55 | hideAvatar, 56 | }: { 57 | style?: ViewStyle 58 | hideAvatar?: boolean 59 | }) { 60 | return ( 61 | 64 | {Array.from({ length: 10 }).map((_, i) => ( 65 | 66 | ))} 67 | 68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /components/Empty.tsx: -------------------------------------------------------------------------------- 1 | import { getScale } from 'color2k' 2 | import { useAtomValue } from 'jotai' 3 | import { Text, View, ViewStyle } from 'react-native' 4 | import Svg, { Ellipse, G, Path } from 'react-native-svg' 5 | 6 | import { colorSchemeAtom } from '@/jotai/themeAtom' 7 | import { uiAtom } from '@/jotai/uiAtom' 8 | import tw from '@/utils/tw' 9 | 10 | export default function Empty({ 11 | description = '暂无数据', 12 | style, 13 | }: { 14 | description?: string 15 | style?: ViewStyle 16 | }) { 17 | const { colors, fontSize } = useAtomValue(uiAtom) 18 | const colorScheme = useAtomValue(colorSchemeAtom) 19 | 20 | return ( 21 | 22 | 23 | 24 | 34 | 41 | 42 | 49 | 50 | 51 | 52 | 55 | {description} 56 | 57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /screens/GItHubMDScreen.tsx: -------------------------------------------------------------------------------- 1 | import { RouteProp, useRoute } from '@react-navigation/native' 2 | import { ScrollView, View } from 'react-native' 3 | import { SafeAreaView } from 'react-native-safe-area-context' 4 | 5 | import Html from '@/components/Html' 6 | import LoadingIndicator from '@/components/LoadingIndicator' 7 | import NavBar, { useNavBarHeight } from '@/components/NavBar' 8 | import { 9 | FallbackComponent, 10 | withQuerySuspense, 11 | } from '@/components/QuerySuspense' 12 | import StyledBlurView from '@/components/StyledBlurView' 13 | import { k } from '@/servicies' 14 | import { RootStackParamList } from '@/types' 15 | import tw from '@/utils/tw' 16 | 17 | export default withQuerySuspense(GItHubMDScreen, { 18 | LoadingComponent: () => { 19 | const { params } = useRoute>() 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | ) 27 | }, 28 | FallbackComponent: props => { 29 | const { params } = useRoute>() 30 | return ( 31 | 32 | 33 | 34 | 35 | ) 36 | }, 37 | }) 38 | 39 | function GItHubMDScreen() { 40 | const { params } = useRoute>() 41 | 42 | const { data: html } = k.other.repoReadme.useSuspenseQuery({ 43 | variables: { url: params.url }, 44 | }) 45 | 46 | const navbarHeight = useNavBarHeight() 47 | 48 | return ( 49 | 50 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /components/StyledToast.tsx: -------------------------------------------------------------------------------- 1 | import { Octicons } from '@expo/vector-icons' 2 | import { getScale } from 'color2k' 3 | import { View } from 'react-native' 4 | import { useSafeAreaInsets } from 'react-native-safe-area-context' 5 | import Toast, { 6 | BaseToast, 7 | ErrorToast, 8 | SuccessToast, 9 | ToastConfig, 10 | ToastConfigParams, 11 | } from 'react-native-toast-message' 12 | 13 | import { store } from '@/jotai/store' 14 | import { colorSchemeAtom } from '@/jotai/themeAtom' 15 | import { formatColor, getUI } from '@/jotai/uiAtom' 16 | import tw from '@/utils/tw' 17 | 18 | const toastConfig: ToastConfig = { 19 | info: props => , 20 | success: props => , 21 | error: props => , 22 | } 23 | 24 | function getToastProps(props: ToastConfigParams) { 25 | const { fontSize, colors } = getUI() 26 | const color = props.type === 'error' ? colors.danger : colors.primary 27 | const iconName = 28 | props.type === 'success' 29 | ? 'check-circle-fill' 30 | : props.type === 'error' 31 | ? 'x-circle-fill' 32 | : 'info' 33 | const bgColor = formatColor( 34 | getScale( 35 | color, 36 | store.get(colorSchemeAtom) === 'dark' ? 'black' : 'white' 37 | )(0.8) 38 | ) 39 | 40 | return { 41 | ...props, 42 | style: tw`rounded-lg border-[${color}] bg-[${bgColor}] border border-solid border-l border-l-[${color}]`, 43 | contentContainerStyle: tw`overflow-hidden pl-0`, 44 | text1Style: tw.style( 45 | `${fontSize.medium} text-[${colors.foreground}]`, 46 | props.text2 ? `font-semibold` : `font-normal` 47 | ), 48 | text2Style: tw`${fontSize.small} text-[${colors.default}]`, 49 | renderLeadingIcon: () => ( 50 | 51 | 52 | 53 | ), 54 | } 55 | } 56 | 57 | export default function StyledToast() { 58 | const safeAreaInsets = useSafeAreaInsets() 59 | return ( 60 | 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /components/StyledImage/AnimatedImageOverlay.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { Pressable, View } from 'react-native' 3 | import { Text } from 'react-native' 4 | 5 | import { imageViewerAtom } from '@/jotai/imageViewerAtom' 6 | import { store } from '@/jotai/store' 7 | import tw from '@/utils/tw' 8 | import useLatest from '@/utils/useLatest' 9 | 10 | let animatingImage = '' 11 | 12 | const animatedListeners = new Set<() => void>() 13 | 14 | export const getAnimatingImage = () => animatingImage 15 | const setAnimatingImage = (nextAnimatedImage: string) => { 16 | if (nextAnimatedImage !== getAnimatingImage()) { 17 | animatingImage = nextAnimatedImage 18 | animatedListeners.forEach(l => l()) 19 | } 20 | } 21 | 22 | store.sub(imageViewerAtom, () => { 23 | if (store.get(imageViewerAtom)?.visible) { 24 | setAnimatingImage('') 25 | } 26 | }) 27 | 28 | export default function AnimatedImageOverlay({ 29 | update, 30 | isAnimating, 31 | uri, 32 | }: { 33 | isAnimating: boolean 34 | update: () => void 35 | uri: string 36 | }) { 37 | const isAnimatingRef = useLatest(isAnimating) 38 | 39 | useEffect(() => { 40 | const listener = () => { 41 | const isAnimating = uri === getAnimatingImage() 42 | if (isAnimating !== isAnimatingRef.current) { 43 | update() 44 | } 45 | } 46 | 47 | animatedListeners.add(listener) 48 | return () => { 49 | animatedListeners.delete(listener) 50 | 51 | if (!animatedListeners.size) { 52 | animatingImage = '' 53 | } 54 | } 55 | // eslint-disable-next-line react-hooks/exhaustive-deps 56 | }, [uri]) 57 | 58 | return ( 59 | { 62 | setAnimatingImage(uri) 63 | }} 64 | disabled={isAnimating} 65 | > 66 | {!isAnimating && ( 67 | 70 | 71 | GIF 72 | 73 | 74 | )} 75 | 76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /components/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import { MaterialCommunityIcons } from '@expo/vector-icons' 2 | import { ReactNode, cloneElement, isValidElement } from 'react' 3 | import { Pressable, PressableProps, View, ViewStyle } from 'react-native' 4 | 5 | import tw from '@/utils/tw' 6 | 7 | const RATIO = 1.7 8 | 9 | export interface IconButtonProps { 10 | backgroundColor?: string 11 | color?: string 12 | activeColor?: string 13 | size?: number 14 | name?: React.ComponentProps['name'] 15 | onPress?: PressableProps['onPress'] 16 | onPressIn?: PressableProps['onPressIn'] 17 | onPressOut?: PressableProps['onPressOut'] 18 | active?: boolean 19 | icon?: ReactNode 20 | pressed?: boolean 21 | style?: ViewStyle 22 | } 23 | 24 | export default function IconButton(props: IconButtonProps) { 25 | return typeof props.pressed === 'boolean' ? ( 26 | 27 | ) : ( 28 | 34 | {({ pressed }) => } 35 | 36 | ) 37 | } 38 | 39 | function IconButtonImpl({ 40 | color, 41 | activeColor, 42 | name, 43 | size = 22.5, 44 | active, 45 | icon, 46 | pressed, 47 | style, 48 | }: IconButtonProps) { 49 | const blurSize = size * RATIO 50 | 51 | return ( 52 | 53 | 62 | {isValidElement(icon) ? ( 63 | cloneElement(icon as any, { 64 | size, 65 | color: pressed || active ? activeColor : color, 66 | }) 67 | ) : ( 68 | 73 | )} 74 | 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /components/Html/TextRenderer_todo.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { 3 | SelectableText, 4 | SelectableTextProps, 5 | } from '@alentoma/react-native-selectable-text' 6 | import * as Clipboard from 'expo-clipboard' 7 | import { decode } from 'js-base64' 8 | import { useContext } from 'react' 9 | import { 10 | CustomBlockRenderer, 11 | getNativePropsForTNode, 12 | } from 'react-native-render-html' 13 | import Toast from 'react-native-toast-message' 14 | 15 | import { HtmlContext } from './HtmlContext' 16 | 17 | // import { RenderContext, SelectableTextAncestor } from './context' 18 | 19 | const menuItems = ['复制', 'Base64 解码'] 20 | 21 | const handleSelection: SelectableTextProps['onSelection'] = async payload => { 22 | const { eventType, content } = payload 23 | switch (eventType) { 24 | case `R_${menuItems[0]}`: 25 | try { 26 | await Clipboard.setStringAsync(content) 27 | Toast.show({ 28 | type: 'success', 29 | text1: `已复制到粘贴板`, 30 | text2: content, 31 | }) 32 | } catch (err) { 33 | // empty 34 | } 35 | break 36 | case `R_${menuItems[1]}`: 37 | try { 38 | const result = decode(content) 39 | if (result) { 40 | await Clipboard.setStringAsync(result) 41 | Toast.show({ 42 | type: 'success', 43 | text1: `已复制到粘贴板`, 44 | text2: result, 45 | }) 46 | } else { 47 | Toast.show({ 48 | type: 'error', 49 | text1: `未识别到有效内容`, 50 | }) 51 | } 52 | } catch (err) { 53 | // empty 54 | } 55 | break 56 | default: 57 | console.log('TO HANDLE SELECTION', payload) 58 | } 59 | } 60 | 61 | const SelectableTextRender: CustomBlockRenderer = props => { 62 | const renderProps = getNativePropsForTNode(props) 63 | const { onOpenURL } = useContext(HtmlContext) 64 | 65 | return ( 66 | 75 | ) 76 | } 77 | 78 | export default SelectableTextRender 79 | -------------------------------------------------------------------------------- /servicies/notification.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio' 2 | import { defaultTo } from 'lodash-es' 3 | import { router } from 'react-query-kit' 4 | 5 | import { removeUnnecessaryPages } from '@/utils/query' 6 | import { request } from '@/utils/request' 7 | 8 | import { getNextPageParam, parseLastPage, parseTopicByATag } from './helper' 9 | import { Member, Notice, PageData, Topic } from './types' 10 | 11 | export const notificationRouter = router(`notification`, { 12 | list: router.infiniteQuery({ 13 | fetcher: async (_, { pageParam, signal }): Promise> => { 14 | const { data } = await request.get(`/notifications?p=${pageParam}`, { 15 | responseType: 'text', 16 | signal, 17 | }) 18 | const $ = load(data) 19 | 20 | return { 21 | page: pageParam, 22 | last_page: parseLastPage($), 23 | list: $('#notifications .cell[id^=n_]') 24 | .map((i, cell) => { 25 | const $td = $(cell).find('tr').eq(0).find('td') 26 | const $avatar = $td.find('a > img') 27 | 28 | return { 29 | id: Number($(cell).attr('id')?.replace('n_', '').trim()), 30 | once: defaultTo( 31 | $td 32 | .eq(1) 33 | .find('.node') 34 | .attr('onclick') 35 | ?.match(/,\s(\d+)\)/)?.[1], 36 | undefined 37 | ), 38 | member: { 39 | username: $avatar.attr('alt'), 40 | avatar: $avatar.attr('src'), 41 | } as Member, 42 | topic: parseTopicByATag($td.eq(1).find('a').eq(1)) as Topic, 43 | prev_action_text: ( 44 | $td.eq(1).find('a').eq(0).get(0)?.nextSibling as any 45 | )?.nodeValue?.trimLeft(), 46 | next_action_text: ( 47 | $td.eq(1).find('a').eq(1).get(0)?.nextSibling as any 48 | )?.nodeValue as string, 49 | created: $td.eq(1).find('.snow').text(), 50 | content: $td.eq(1).find('.payload').html(), 51 | } as Notice 52 | }) 53 | .get(), 54 | } 55 | }, 56 | initialPageParam: 1, 57 | getNextPageParam, 58 | structuralSharing: false, 59 | use: [removeUnnecessaryPages], 60 | }), 61 | 62 | delete: router.mutation({ 63 | mutationFn: ({ id, once }) => 64 | request.post(`/delete/notification/${id}?once=${once}`, { 65 | responseType: 'text', 66 | }), 67 | }), 68 | }) 69 | -------------------------------------------------------------------------------- /components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { Ionicons } from '@expo/vector-icons' 2 | import { useAtomValue } from 'jotai' 3 | import { omit } from 'lodash-es' 4 | import { forwardRef } from 'react' 5 | import { 6 | Pressable, 7 | PressableProps, 8 | TextInput, 9 | TextInputProps, 10 | TouchableOpacity, 11 | ViewStyle, 12 | } from 'react-native' 13 | 14 | import { uiAtom } from '@/jotai/uiAtom' 15 | import tw from '@/utils/tw' 16 | 17 | const SearchBar = forwardRef< 18 | TextInput, 19 | { 20 | style?: ViewStyle 21 | editable?: boolean 22 | onPress?: PressableProps['onPress'] 23 | value?: string 24 | onChangeText?: TextInputProps['onChangeText'] 25 | onSubmitEditing?: TextInputProps['onSubmitEditing'] 26 | autoFocus?: boolean 27 | placeholder?: string 28 | } 29 | >( 30 | ( 31 | { 32 | style, 33 | editable = true, 34 | onPress, 35 | value, 36 | onChangeText, 37 | onSubmitEditing, 38 | autoFocus, 39 | placeholder, 40 | }, 41 | ref 42 | ) => { 43 | const { colors, fontSize } = useAtomValue(uiAtom) 44 | return ( 45 | 52 | 58 | 77 | {editable && !!value && ( 78 | { 80 | onChangeText?.('') 81 | }} 82 | style={tw`h-4 w-4 items-center justify-center rounded-full mr-3 bg-[${colors.primary}]`} 83 | > 84 | 85 | 86 | )} 87 | 88 | ) 89 | } 90 | ) 91 | 92 | export default SearchBar 93 | -------------------------------------------------------------------------------- /utils/query.ts: -------------------------------------------------------------------------------- 1 | import NetInfo from '@react-native-community/netinfo' 2 | import { 3 | QueryClient, 4 | focusManager, 5 | hashKey, 6 | onlineManager, 7 | } from '@tanstack/react-query' 8 | import { first, isArray, isObjectLike } from 'lodash-es' 9 | import { useMemo } from 'react' 10 | import { AppState, Platform } from 'react-native' 11 | import { 12 | InfiniteQueryHook, 13 | Middleware, 14 | QueryHook, 15 | getKey, 16 | } from 'react-query-kit' 17 | 18 | export const queryClient = new QueryClient({ 19 | defaultOptions: { 20 | queries: { 21 | gcTime: 1000 * 60 * 60 * 24, // 24 hours 22 | retry: 2, 23 | refetchOnWindowFocus: false, 24 | networkMode: 'offlineFirst', 25 | }, 26 | mutations: { networkMode: 'offlineFirst' }, 27 | }, 28 | }) 29 | 30 | if (Platform.OS !== 'web') { 31 | AppState.addEventListener('change', status => { 32 | focusManager.setFocused(status === 'active') 33 | }) 34 | 35 | NetInfo.addEventListener(state => { 36 | onlineManager.setOnline( 37 | state.isConnected != null && 38 | state.isConnected && 39 | Boolean(state.isInternetReachable) 40 | ) 41 | }) 42 | } 43 | 44 | export const removeUnnecessaryPages: Middleware< 45 | InfiniteQueryHook 46 | > = useNext => options => { 47 | useMemo(() => { 48 | if (options.enabled !== false && !!options.getNextPageParam) { 49 | const query = queryClient 50 | .getQueryCache() 51 | .get( 52 | (options.queryKeyHashFn ?? hashKey)( 53 | getKey(options.queryKey, options.variables) 54 | ) 55 | ) 56 | 57 | if (!query) return 58 | 59 | const data: any = query.state.data 60 | const isInfiniteQuery = 61 | isObjectLike(data) && isArray(data.pages) && isArray(data.pageParams) 62 | 63 | if ( 64 | isInfiniteQuery && 65 | query.state.status === 'success' && 66 | data.pages.length >= 2 67 | ) { 68 | // only keep one page before mount 69 | query.setData({ 70 | pages: [first(data.pages)], 71 | pageParams: [first(data.pageParams)], 72 | }) 73 | } 74 | } 75 | 76 | // eslint-disable-next-line react-hooks/exhaustive-deps 77 | }, []) 78 | 79 | return useNext(options) 80 | } 81 | 82 | export const disabledIfFetched: Middleware> = 83 | useNext => options => { 84 | return useNext({ 85 | ...options, 86 | enabled: 87 | options.enabled ?? 88 | !queryClient 89 | .getQueryCache() 90 | .get( 91 | (options.queryKeyHashFn ?? hashKey)( 92 | getKey(options.queryKey, options.variables) 93 | ) 94 | ), 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /components/V2exWebview/v2exMessage.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig, AxiosResponse } from 'axios' 2 | import { noop, pick, uniqueId } from 'lodash-es' 3 | 4 | import { sleep } from '@/utils/sleep' 5 | 6 | class SendMEssageTimeoutError extends Error { 7 | constructor() { 8 | super('请检查您的网络设置') 9 | this.name = '应用连接失败' 10 | } 11 | } 12 | 13 | class V2exMessage { 14 | linsteners: Map void> = new Map() 15 | loadV2exWebviewPromise: Promise = Promise.resolve() 16 | loadingV2exWebview: boolean = true 17 | injectRequestScript: (arg: { 18 | id: string 19 | config: AxiosRequestConfig 20 | }) => void = noop 21 | clearWebviewCache: () => void = noop 22 | reloadWebview: () => void = () => Promise.resolve() 23 | timeout: boolean = false 24 | injectCheckConnectScript: () => Promise = () => Promise.resolve() 25 | checkConnectPromise: Promise = Promise.resolve() 26 | checkingConnect: boolean = false 27 | checkConnectTimeElapsed: number = 0 28 | 29 | constructor() { 30 | this.sendMessage = this.sendMessage.bind(this) 31 | this.checkConnect = this.checkConnect.bind(this) 32 | } 33 | 34 | async sendMessage( 35 | config: AxiosRequestConfig 36 | ): Promise> { 37 | await this.checkConnect() 38 | 39 | const id = uniqueId() 40 | 41 | try { 42 | return await Promise.race([ 43 | new Promise>(async resolve => { 44 | this.injectRequestScript({ 45 | id, 46 | config: pick(config, [ 47 | 'timeout', 48 | 'baseURL', 49 | 'responseType', 50 | 'method', 51 | 'url', 52 | 'headers', 53 | 'withCredentials', 54 | 'data', 55 | 'transformResponseScript', 56 | ]), 57 | }) 58 | this.linsteners.set(id, resolve) 59 | }), 60 | sleep(config.timeout || 5 * 1000).then(() => 61 | Promise.reject(new SendMEssageTimeoutError()) 62 | ), 63 | ]) 64 | } catch (error) { 65 | if (error instanceof SendMEssageTimeoutError) { 66 | this.timeout = true 67 | } 68 | return Promise.reject(error) 69 | } finally { 70 | this.linsteners.delete(id) 71 | } 72 | } 73 | 74 | async checkConnect() { 75 | if (this.loadingV2exWebview) return this.loadV2exWebviewPromise 76 | 77 | if (this.checkingConnect) return this.checkConnectPromise 78 | 79 | this.checkingConnect = true 80 | this.checkConnectPromise = this.injectCheckConnectScript().catch(() => 81 | this.reloadWebview() 82 | ) 83 | this.checkConnectPromise.finally(() => { 84 | this.checkingConnect = false 85 | }) 86 | 87 | return this.checkConnectPromise 88 | } 89 | } 90 | 91 | export default new V2exMessage() 92 | -------------------------------------------------------------------------------- /servicies/my.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio' 2 | import { router } from 'react-query-kit' 3 | 4 | import { queryClient, removeUnnecessaryPages } from '@/utils/query' 5 | import { request } from '@/utils/request' 6 | 7 | import { 8 | getNextPageParam, 9 | parseLastPage, 10 | parseTopicItems, 11 | pasreArgByATag, 12 | } from './helper' 13 | import { nodeRouter } from './node' 14 | import { Member, Node, PageData, Topic } from './types' 15 | 16 | export const myRouter = router(`my`, { 17 | nodes: router.query({ 18 | fetcher: async (_, { signal }): Promise => { 19 | const [data, nodes] = await Promise.all([ 20 | request.get(`/my/nodes`, { signal }).then(res => res.data), 21 | queryClient.ensureQueryData(nodeRouter.all.getFetchOptions()), 22 | ]) 23 | const nodeMap = Object.fromEntries(nodes.map(item => [item.name, item])) 24 | const $ = load(data) 25 | return $('#my-nodes a') 26 | .map((i, a) => nodeMap[pasreArgByATag($(a), 'go')]) 27 | .get() 28 | .filter(Boolean) 29 | }, 30 | }), 31 | 32 | following: router.infiniteQuery({ 33 | fetcher: async ( 34 | _, 35 | { pageParam, signal } 36 | ): Promise & { following: Member[] }> => { 37 | const { data } = await request.get(`/my/following?p=${pageParam}`, { 38 | responseType: 'text', 39 | signal, 40 | }) 41 | const $ = load(data) 42 | 43 | let following: Member[] = [] 44 | 45 | $('#Rightbar .box').each((i, elem) => { 46 | let $box = $(elem) 47 | if ($box.find('.cell:first-child').text().includes('我关注的人')) { 48 | following = $box 49 | .find('a > img') 50 | .map((i, img) => { 51 | const $avatar = $(img) 52 | return { 53 | username: $avatar.attr('alt'), 54 | avatar: $avatar.attr('src'), 55 | } as Member 56 | }) 57 | .get() 58 | return false 59 | } 60 | }) 61 | 62 | return { 63 | page: pageParam, 64 | last_page: parseLastPage($), 65 | list: parseTopicItems($, '#Main .box .cell.item'), 66 | following, 67 | } 68 | }, 69 | initialPageParam: 1, 70 | getNextPageParam, 71 | structuralSharing: false, 72 | use: [removeUnnecessaryPages], 73 | }), 74 | 75 | topics: router.infiniteQuery, void>({ 76 | fetcher: async (_, { pageParam, signal }) => { 77 | const { data } = await request.get(`/my/topics?p=${pageParam}`, { 78 | responseType: 'text', 79 | signal, 80 | }) 81 | const $ = load(data) 82 | 83 | return { 84 | page: pageParam, 85 | last_page: parseLastPage($), 86 | list: parseTopicItems($, '#Main .box .cell.item'), 87 | } 88 | }, 89 | initialPageParam: 1, 90 | getNextPageParam, 91 | structuralSharing: false, 92 | use: [removeUnnecessaryPages], 93 | }), 94 | }) 95 | -------------------------------------------------------------------------------- /components/Drawer.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from '@react-navigation/native' 2 | import { useAtomValue } from 'jotai' 3 | import { noop } from 'lodash-es' 4 | import { createContext, useContext, useEffect, useState } from 'react' 5 | import { Platform, StyleSheet } from 'react-native' 6 | import { Drawer as RawDrawer } from 'react-native-drawer-layout' 7 | import { useSafeAreaFrame } from 'react-native-safe-area-context' 8 | 9 | import { uiAtom } from '@/jotai/uiAtom' 10 | 11 | const DRAWER_BORDER_RADIUS = 16 12 | 13 | const getDefaultSidebarWidth = ({ 14 | height, 15 | width, 16 | }: { 17 | height: number 18 | width: number 19 | }) => { 20 | /* 21 | * Default sidebar width is screen width - header height 22 | * with a max width of 280 on mobile and 320 on tablet 23 | * https://material.io/components/navigation-drawer 24 | */ 25 | const smallerAxisSize = Math.min(height, width) 26 | const isLandscape = width > height 27 | const isTablet = smallerAxisSize >= 600 28 | const appBarHeight = Platform.OS === 'ios' ? (isLandscape ? 32 : 44) : 56 29 | const maxWidth = isTablet ? 320 : 280 30 | 31 | return Math.min(smallerAxisSize - appBarHeight, maxWidth) 32 | } 33 | 34 | const DrwaerContext = createContext< 35 | [boolean, React.Dispatch>] 36 | >([false, noop]) 37 | 38 | export function useDrawer() { 39 | return useContext(DrwaerContext) 40 | } 41 | 42 | export default function Drawer({ 43 | drawerType = 'slide', 44 | drawerPosition = 'left', 45 | drawerStyle, 46 | ...props 47 | }: Partial[0]>) { 48 | const { colors } = useAtomValue(uiAtom) 49 | const dimensions = useSafeAreaFrame() 50 | const state = useState(false) 51 | const [open, setOpen] = state 52 | 53 | return ( 54 | 55 | setOpen(false)} 58 | onOpen={() => setOpen(true)} 59 | drawerType={drawerType} 60 | layout={dimensions} 61 | drawerStyle={[ 62 | { 63 | width: getDefaultSidebarWidth(dimensions), 64 | }, 65 | drawerType === 'permanent' && 66 | (drawerPosition === 'left' 67 | ? { 68 | borderEndColor: colors.divider, 69 | borderEndWidth: StyleSheet.hairlineWidth, 70 | } 71 | : { 72 | borderStartColor: colors.divider, 73 | borderStartWidth: StyleSheet.hairlineWidth, 74 | }), 75 | 76 | drawerType === 'front' && 77 | (drawerPosition === 'left' 78 | ? { 79 | borderTopRightRadius: DRAWER_BORDER_RADIUS, 80 | borderBottomRightRadius: DRAWER_BORDER_RADIUS, 81 | } 82 | : { 83 | borderTopLeftRadius: DRAWER_BORDER_RADIUS, 84 | borderBottomLeftRadius: DRAWER_BORDER_RADIUS, 85 | }), 86 | drawerStyle, 87 | ]} 88 | {...(props as any)} 89 | /> 90 | 91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /utils/url.ts: -------------------------------------------------------------------------------- 1 | import { Linking } from 'react-native' 2 | import Toast from 'react-native-toast-message' 3 | 4 | import { baseUrlAtom, v2exURL } from '@/jotai/baseUrlAtom' 5 | import { store } from '@/jotai/store' 6 | 7 | export function getURLSearchParams(url?: string): Record { 8 | if (!url) return {} 9 | const query = url.includes('?') ? url.split('?')[1] : url 10 | const params = Object.fromEntries( 11 | query.split('&').map(pair => pair.split('=')) 12 | ) 13 | return params 14 | } 15 | 16 | export function resolveURL(url: string) { 17 | if (url.startsWith('//')) return `https:${url}` 18 | if (url.startsWith('/')) return `${getBaseURL()}${url}` 19 | if (url.startsWith('about://')) return url.replace('about://', getBaseURL()) 20 | return url 21 | } 22 | 23 | const svgURLs = ['img.shields.io', 'badgen.net', 'img.badgesize.io'] 24 | export function isSvgURL(url: string) { 25 | return url.includes('.svg') || svgURLs.some(svgURL => url.includes(svgURL)) 26 | } 27 | 28 | export function isGifURL(url: string) { 29 | return url.includes('.gif') 30 | } 31 | 32 | export async function openURL(url: string) { 33 | const supported = await Linking.canOpenURL(url) 34 | 35 | if (!supported) { 36 | Toast.show({ 37 | type: 'error', 38 | text1: '不支持打开该链接', 39 | }) 40 | return Promise.reject(new Error(`This url is unsupported`)) 41 | } 42 | 43 | try { 44 | await Linking.openURL(url) 45 | } catch (error) { 46 | Toast.show({ 47 | type: 'error', 48 | text1: '打开链接失败', 49 | }) 50 | return Promise.reject(new Error(`Failed to openURL`)) 51 | } 52 | } 53 | 54 | export function getBaseURL() { 55 | return store.get(baseUrlAtom) || v2exURL 56 | } 57 | 58 | export function isValidURL(url: string) { 59 | const urlPattern = new RegExp( 60 | '^(https?:\\/\\/)?' + // validate protocol 61 | '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // validate domain name 62 | '((\\d{1,3}\\.){3}\\d{1,3}))' + // validate OR ip (v4) address 63 | '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // validate port and path 64 | '(\\?[;&a-z\\d%_.~+=-]*)?' + // validate query string 65 | '(\\#[-a-z\\d_]*)?$', 66 | 'i' 67 | ) // validate fragment locator 68 | return !!urlPattern.test(url) 69 | } 70 | 71 | /** 72 | * Generate dataURI raw BMP image 73 | * 74 | * @param width - image width (num of pixels) 75 | * @param pixels - 1D array of RGBA pixels (pixel = 4 numbers in 76 | * range 0-255; staring from left bottom corner) 77 | * @return dataURI string 78 | */ 79 | export function genBMPUri(width: number, pixels: number[]) { 80 | const LE = (n: number) => 81 | (n + 2 ** 32).toString(16).match(/\B../g)!.reverse().join('') 82 | const wh = LE(width) + LE(pixels.length / width / 4) 83 | const size = LE(108 + pixels.length) 84 | const r = (n: number) => '0'.repeat(n) 85 | const head = `424d${size}ZZ7AZ006CZ00${wh}01002Z3${r(50)}FFZFFZFFZZZFF${r( 86 | 104 87 | )}` 88 | 89 | return ( 90 | 'data:image/bmp,' + 91 | [ 92 | ...head.replace(/Z/g, '0000').match(/../g)!, 93 | ...pixels.map(x => x.toString(16).padStart(2, '0')), 94 | ] 95 | .map(x => '%' + x) 96 | .join('') 97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /screens/WebSigninScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { useRef, useState } from 'react' 3 | import { Platform, View } from 'react-native' 4 | import WebView from 'react-native-webview' 5 | 6 | import LoadingIndicator from '@/components/LoadingIndicator' 7 | import NavBar, { useNavBarHeight } from '@/components/NavBar' 8 | import StyledBlurView from '@/components/StyledBlurView' 9 | import { uiAtom } from '@/jotai/uiAtom' 10 | import { navigation } from '@/navigation/navigationRef' 11 | import { queryClient } from '@/utils/query' 12 | import tw from '@/utils/tw' 13 | import { getBaseURL } from '@/utils/url' 14 | 15 | export default function WebSigninScreen() { 16 | const [isLoading, setIsLoading] = useState(true) 17 | const [timestamp] = useState(Date.now()) 18 | 19 | const webViewRef = useRef(null) 20 | 21 | const isGobackRef = useRef(false) 22 | 23 | function goBackWithRefetch() { 24 | if (isGobackRef.current) return 25 | isGobackRef.current = true 26 | navigation.pop(2) 27 | queryClient.refetchQueries({ type: 'active' }) 28 | } 29 | 30 | const navbarHeight = useNavBarHeight() 31 | 32 | const { colors } = useAtomValue(uiAtom) 33 | 34 | return ( 35 | 36 | {isLoading && } 37 | 38 | 39 | { 42 | setIsLoading(false) 43 | }} 44 | style={tw.style(`flex-1`, { 45 | marginTop: navbarHeight, 46 | })} 47 | source={{ uri: `${getBaseURL()}/signin` }} 48 | javaScriptEnabled={true} 49 | domStorageEnabled={true} 50 | decelerationRate={0.998} 51 | sharedCookiesEnabled={true} 52 | startInLoadingState={true} 53 | scalesPageToFit={true} 54 | // cacheEnabled={false} 55 | // cacheMode="LOAD_NO_CACHE" 56 | // incognito={true} 57 | renderLoading={() => } 58 | onLoad={() => { 59 | webViewRef.current?.injectJavaScript( 60 | `ReactNativeWebView.postMessage($('#menu-body > div:last > a').attr("href").includes("signout") && !$(".header").text().includes("两步验证"))` 61 | ) 62 | }} 63 | onMessage={async event => { 64 | const isSignin = event.nativeEvent.data 65 | console.log(isSignin) 66 | 67 | if (isSignin === 'true' && Date.now() - timestamp > 5000) { 68 | goBackWithRefetch() 69 | } 70 | }} 71 | userAgent={ 72 | Platform.OS === 'android' 73 | ? `Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36` 74 | : `Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1` 75 | } 76 | /> 77 | 78 | 79 | 80 | 81 | 85 | 86 | 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /components/StyledButton.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { ReactNode, cloneElement, isValidElement } from 'react' 3 | import { PressableProps, Text, TextProps, ViewStyle } from 'react-native' 4 | 5 | import { uiAtom } from '@/jotai/uiAtom' 6 | import tw from '@/utils/tw' 7 | 8 | import DebouncedPressable from './DebouncedPressable' 9 | 10 | export interface StyledButtonProps { 11 | size?: 'middle' | 'large' | 'small' | 'mini' 12 | type?: 'default' | 'primary' | 'tag' 13 | shape?: 'default' | 'rounded' | 'rectangular' 14 | onPress?: PressableProps['onPress'] 15 | children?: string 16 | ghost?: boolean 17 | style?: ViewStyle 18 | textProps?: TextProps 19 | pressable?: boolean 20 | icon?: ReactNode 21 | color?: string 22 | } 23 | 24 | export default function StyledButton({ 25 | size = 'middle', 26 | type = 'default', 27 | onPress, 28 | children, 29 | ghost, 30 | style, 31 | textProps, 32 | shape = 'default', 33 | pressable = true, 34 | color, 35 | icon, 36 | }: StyledButtonProps) { 37 | const { colors, fontSize } = useAtomValue(uiAtom) 38 | 39 | const textSize = tw.style(fontSize[size === 'mini' ? 'small' : 'medium']) 40 | .fontSize as number 41 | 42 | const buttonStyle = tw.style( 43 | size === 'middle' && `h-9 px-4`, 44 | size === 'large' && `h-[52px] px-8`, 45 | size === 'small' && `h-8 px-3`, 46 | size === 'mini' && `px-1 py-0.5`, 47 | shape === 'default' && (size === 'mini' ? tw`rounded` : tw`rounded-lg`), 48 | shape === 'rounded' && `rounded-full`, 49 | shape === 'rectangular' && `rounded-none`, 50 | `flex-row items-center justify-center rounded-full border border-solid gap-1`, 51 | type === 'primary' && 52 | `border-[${colors.primary}] text-[${colors.primary}] dark:text-white`, 53 | !ghost && 54 | type === 'primary' && 55 | tw`bg-[${colors.primary}] text-[${colors.primaryContent}]`, 56 | type === 'tag' && `border-[${colors.base200}] text-[${colors.default}]`, 57 | !ghost && 58 | type === 'tag' && 59 | `bg-[${colors.base200}] text-[${colors.default}]`, 60 | type === 'default' && 61 | `border-[#0f1419] dark:border-white text-[#0f1419] dark:text-white`, 62 | !ghost && 63 | type === 'default' && 64 | `bg-[#0f1419] dark:bg-white text-white dark:text-[#0f1419]`, 65 | color && `border-[${color}] text-[${color}]`, 66 | !ghost && color && `bg-[${color}] text-[#fff]`, 67 | !pressable && tw`opacity-80`, 68 | style 69 | ) 70 | 71 | return ( 72 | 74 | tw.style(buttonStyle, pressed && pressable && `opacity-80`) 75 | } 76 | onPress={ev => { 77 | if (pressable) { 78 | onPress?.(ev) 79 | } 80 | }} 81 | > 82 | {isValidElement(icon) && 83 | cloneElement(icon as any, { 84 | color: buttonStyle.color, 85 | })} 86 | 97 | {children} 98 | 99 | 100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /components/StyledImageViewer.tsx: -------------------------------------------------------------------------------- 1 | import { Ionicons } from '@expo/vector-icons' 2 | import { Image } from 'expo-image' 3 | import * as Sharing from 'expo-sharing' 4 | import { useAtomValue } from 'jotai' 5 | import { ComponentProps } from 'react' 6 | import { Dimensions, Modal, Platform, Text, View } from 'react-native' 7 | import ImageViewer from 'react-native-image-zoom-viewer' 8 | import { useSafeAreaInsets } from 'react-native-safe-area-context' 9 | 10 | import { uiAtom } from '@/jotai/uiAtom' 11 | import tw from '@/utils/tw' 12 | 13 | import IconButton from './IconButton' 14 | import { NAV_BAR_HEIGHT } from './NavBar' 15 | 16 | export interface StyledImageViewerProps 17 | extends Omit, 'onCancel'> { 18 | visible?: boolean 19 | onClose?: () => void 20 | } 21 | 22 | export default function StyledImageViewer({ 23 | visible, 24 | onClose, 25 | ...props 26 | }: StyledImageViewerProps) { 27 | const safeAreaInsets = useSafeAreaInsets() 28 | const { fontSize } = useAtomValue(uiAtom) 29 | 30 | return ( 31 | 38 | ( 56 | 59 | } 64 | {...{ 65 | [Platform.OS === 'android' ? 'onPressIn' : 'onPress']: onClose, 66 | }} 67 | /> 68 | 69 | { 76 | Sharing.shareAsync(props.imageUrls[currentIndex!].url) 77 | }, 78 | }} 79 | /> 80 | 81 | )} 82 | renderIndicator={(currentIndex, allSize) => ( 83 | 86 | 87 | {currentIndex + ' / ' + allSize} 88 | 89 | 90 | )} 91 | renderImage={imageProps => ( 92 | 93 | )} 94 | saveToLocalByLongPress={false} 95 | {...props} 96 | /> 97 | 98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "v2ex", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "start": "expo start --offline", 7 | "android": "expo start --android", 8 | "ios": "expo start --ios", 9 | "web": "expo start --web", 10 | "postinstall": "npx patch-package" 11 | }, 12 | "dependencies": { 13 | "@expo/react-native-action-sheet": "^4.0.1", 14 | "@expo/vector-icons": "^14.1.0", 15 | "@hookform/resolvers": "^5.0.1", 16 | "@react-native-assets/slider": "^7.0.7", 17 | "@react-native-async-storage/async-storage": "2.2.0", 18 | "@react-native-community/datetimepicker": "8.4.4", 19 | "@react-native-community/netinfo": "11.4.1", 20 | "@react-native-cookies/cookies": "^6.2.1", 21 | "@react-navigation/drawer": "^7.0.19", 22 | "@react-navigation/native": "^7.1.24", 23 | "@react-navigation/native-stack": "^7.1.14", 24 | "@tanstack/react-query": "^5.12.2", 25 | "@types/spark-md5": "^3.0.2", 26 | "axios": "^0.27.2", 27 | "cheerio": "1.0.0-rc.12", 28 | "color2k": "^2.0.3", 29 | "dayjs": "^1.11.5", 30 | "expo": "~54.0.26", 31 | "expo-blur": "~15.0.7", 32 | "expo-build-properties": "~1.0.9", 33 | "expo-clipboard": "~8.0.7", 34 | "expo-constants": "~18.0.10", 35 | "expo-device": "~8.0.9", 36 | "expo-file-system": "~19.0.19", 37 | "expo-image": "~3.0.10", 38 | "expo-image-picker": "~17.0.8", 39 | "expo-linking": "~8.0.9", 40 | "expo-navigation-bar": "~5.0.9", 41 | "expo-sharing": "~14.0.7", 42 | "expo-splash-screen": "~31.0.11", 43 | "expo-status-bar": "~3.0.8", 44 | "expo-updates": "~29.0.14", 45 | "highlight.js": "^11.6.0", 46 | "immer": "^10.0.3", 47 | "jotai": "^1.12.0", 48 | "js-base64": "^3.7.5", 49 | "lodash-es": "^4.17.21", 50 | "react": "19.1.0", 51 | "react-dom": "19.1.0", 52 | "react-error-boundary": "^4.1.2", 53 | "react-hook-form": "^7.56.1", 54 | "react-native": "0.81.5", 55 | "react-native-bouncy-checkbox": "^3.0.7", 56 | "react-native-drag-sort": "^2.4.4", 57 | "react-native-drawer-layout": "^4.0.4", 58 | "react-native-gesture-handler": "~2.28.0", 59 | "react-native-image-zoom-viewer": "^3.0.1", 60 | "react-native-modal-datetime-picker": "^18.0.0", 61 | "react-native-pager-view": "6.9.1", 62 | "react-native-reanimated": "~4.1.1", 63 | "react-native-render-html": "^6.3.4", 64 | "react-native-safe-area-context": "~5.6.0", 65 | "react-native-screens": "~4.16.0", 66 | "react-native-svg": "15.12.1", 67 | "react-native-system-navigation-bar": "^2.8.0", 68 | "react-native-tab-view": "^4.0.11", 69 | "react-native-toast-message": "^2.2.1", 70 | "react-native-webview": "13.15.0", 71 | "react-native-worklets": "0.5.1", 72 | "react-native-youtube-iframe": "^2.3.0", 73 | "react-query-kit": "^3.2.0", 74 | "showdown": "^2.1.0", 75 | "spark-md5": "^3.0.2", 76 | "suspend-react": "^0.1.3", 77 | "twrnc": "^4.6.0", 78 | "zod": "^3.24.3" 79 | }, 80 | "devDependencies": { 81 | "@babel/core": "^7.24.0", 82 | "@react-native-community/eslint-config": "^3.1.0", 83 | "@trivago/prettier-plugin-sort-imports": "^3.3.0", 84 | "@types/lodash-es": "^4.17.6", 85 | "@types/react": "~19.1.10", 86 | "@types/showdown": "^2.0.1", 87 | "babel-plugin-module-resolver": "^4.1.0", 88 | "babel-plugin-react-compiler": "^19.1.0-rc.2", 89 | "eslint": "^8.23.1", 90 | "prettier": "^2.7.1", 91 | "react-test-renderer": "18.0.0", 92 | "typescript": "~5.9.2" 93 | }, 94 | "private": true 95 | } 96 | -------------------------------------------------------------------------------- /components/Html/helper.ts: -------------------------------------------------------------------------------- 1 | import * as Clipboard from 'expo-clipboard' 2 | import { first, isArray } from 'lodash-es' 3 | import { 4 | HTMLContentModel, 5 | HTMLElementModel, 6 | RenderHTMLProps, 7 | } from 'react-native-render-html' 8 | import Toast from 'react-native-toast-message' 9 | 10 | import { enabledWebviewAtom } from '@/jotai/enabledWebviewAtom' 11 | import { store } from '@/jotai/store' 12 | import { navigation } from '@/navigation/navigationRef' 13 | import { BASE64_PREFIX } from '@/servicies/helper' 14 | import tw from '@/utils/tw' 15 | import { openURL, resolveURL } from '@/utils/url' 16 | 17 | const defaultProps: Omit = { 18 | domVisitors: { 19 | onElement: (el: any) => { 20 | const firstChild: any = first( 21 | isArray(el.children) 22 | ? el.children.filter((child: any) => !!child?.name) 23 | : [] 24 | ) 25 | 26 | if (firstChild && firstChild.name === 'p') { 27 | firstChild.attribs = { class: `mt-0 ${firstChild.attribs?.class}` } 28 | } 29 | }, 30 | }, 31 | 32 | classesStyles: { 33 | 'mt-0': tw`mt-0`, 34 | }, 35 | 36 | customHTMLElementModels: { 37 | iframe: HTMLElementModel.fromCustomModel({ 38 | tagName: 'iframe', 39 | contentModel: HTMLContentModel.block, 40 | }), 41 | 42 | input: HTMLElementModel.fromCustomModel({ 43 | tagName: 'input', 44 | contentModel: HTMLContentModel.textual, 45 | }), 46 | }, 47 | 48 | defaultTextProps: { 49 | selectable: true, 50 | }, 51 | 52 | enableExperimentalMarginCollapsing: true, 53 | } 54 | 55 | export function getDefaultProps({ 56 | inModalScreen, 57 | }: { 58 | inModalScreen?: boolean 59 | }): Omit { 60 | return { 61 | ...defaultProps, 62 | renderersProps: { 63 | a: { 64 | onPress: async (_, href: string) => { 65 | if (href.startsWith(BASE64_PREFIX)) { 66 | const decodedContent = href.slice(BASE64_PREFIX.length) 67 | Clipboard.setStringAsync(decodedContent).then(() => 68 | Toast.show({ 69 | type: 'success', 70 | text1: `已复制到粘贴板`, 71 | text2: decodedContent, 72 | }) 73 | ) 74 | return 75 | } 76 | 77 | if (inModalScreen) { 78 | navigation.goBack() 79 | } 80 | 81 | const resolvedURL = resolveURL(href) 82 | 83 | for (const path of ['t', 'member', 'go']) { 84 | const matched = resolvedURL.match( 85 | new RegExp( 86 | `^(?:https?:\\/\\/)?(?:\\w+\\.)?v2ex\\.com\/${path}\/(\\w+)` 87 | ) 88 | ) 89 | if (!matched) continue 90 | const arg = matched[1] 91 | 92 | switch (path) { 93 | case 't': 94 | navigation.push('TopicDetail', { 95 | id: parseInt(arg, 10), 96 | }) 97 | return 98 | case 'member': 99 | navigation.push('MemberDetail', { username: arg }) 100 | return 101 | case 'go': 102 | navigation.push('NodeTopics', { 103 | name: arg, 104 | }) 105 | return 106 | } 107 | } 108 | 109 | if (store.get(enabledWebviewAtom)) { 110 | navigation.navigate('Webview', { url: resolvedURL }) 111 | } else { 112 | openURL(resolvedURL) 113 | } 114 | }, 115 | }, 116 | }, 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /components/topic/XnaItem.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { compact, isEqual } from 'lodash-es' 3 | import { memo } from 'react' 4 | import { Text, View } from 'react-native' 5 | 6 | import { uiAtom } from '@/jotai/uiAtom' 7 | import { getCurrentRouteName, navigation } from '@/navigation/navigationRef' 8 | import { Xna } from '@/servicies' 9 | import { isTablet } from '@/utils/tablet' 10 | import tw from '@/utils/tw' 11 | import useUpdate from '@/utils/useUpdate' 12 | 13 | import DebouncedPressable from '../DebouncedPressable' 14 | import Separator from '../Separator' 15 | import StyledButton from '../StyledButton' 16 | import StyledImage from '../StyledImage' 17 | 18 | export interface XnaItemProps { 19 | xna: Xna 20 | hideAvatar?: boolean 21 | } 22 | 23 | export default memo(XnaItem, (prev, next) => isEqual(prev.xna, next.xna)) 24 | 25 | const readedXnas = new Set() 26 | 27 | function XnaItem({ xna, hideAvatar }: XnaItemProps) { 28 | const update = useUpdate() 29 | const { colors, fontSize } = useAtomValue(uiAtom) 30 | 31 | return ( 32 | { 35 | readedXnas.add(xna.id) 36 | update() 37 | navigation.push('Webview', { url: xna.id }) 38 | }} 39 | > 40 | {!hideAvatar && ( 41 | 42 | { 44 | navigation.push('MemberDetail', { 45 | username: xna.member?.username!, 46 | }) 47 | }} 48 | style={tw`pr-3`} 49 | > 50 | 55 | 56 | 57 | )} 58 | 59 | 60 | { 64 | navigation.push('MemberDetail', { 65 | username: xna.member?.username!, 66 | }) 67 | }} 68 | > 69 | {xna.member?.username} 70 | 71 | 72 | {!!xna.node?.title && ( 73 | { 77 | navigation.push('Webview', { url: xna.node?.name! }) 78 | }} 79 | > 80 | {xna.node?.title} 81 | 82 | )} 83 | 84 | {xna.pin_to_top && ( 85 | 86 | 置顶 87 | 88 | )} 89 | 90 | 91 | 99 | {xna.title} 100 | 101 | 102 | 103 | 107 | {xna.last_touched} 108 | 109 | 110 | 111 | 112 | ) 113 | } 114 | -------------------------------------------------------------------------------- /screens/MyTopicsScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { uniqBy } from 'lodash-es' 3 | import { useCallback, useMemo } from 'react' 4 | import { FlatList, ListRenderItem, View } from 'react-native' 5 | import { SafeAreaView } from 'react-native-safe-area-context' 6 | 7 | import NavBar, { useNavBarHeight } from '@/components/NavBar' 8 | import { 9 | FallbackComponent, 10 | withQuerySuspense, 11 | } from '@/components/QuerySuspense' 12 | import RefetchingIndicator from '@/components/RefetchingIndicator' 13 | import { LineSeparator } from '@/components/Separator' 14 | import StyledActivityIndicator from '@/components/StyledActivityIndicator' 15 | import StyledBlurView from '@/components/StyledBlurView' 16 | import StyledRefreshControl from '@/components/StyledRefreshControl' 17 | import TopicPlaceholder from '@/components/placeholder/TopicPlaceholder' 18 | import TopicItem from '@/components/topic/TopicItem' 19 | import { colorSchemeAtom } from '@/jotai/themeAtom' 20 | import { Topic, k } from '@/servicies' 21 | import tw from '@/utils/tw' 22 | import { useRefreshByUser } from '@/utils/useRefreshByUser' 23 | 24 | export default withQuerySuspense(MyTopicsScreen, { 25 | LoadingComponent: () => ( 26 | 27 | 28 | 29 | 30 | ), 31 | fallbackRender: props => ( 32 | 33 | 34 | 35 | 36 | ), 37 | }) 38 | 39 | function MyTopicsScreen() { 40 | const { 41 | data, 42 | refetch, 43 | hasNextPage, 44 | fetchNextPage, 45 | isFetchingNextPage, 46 | isFetching, 47 | } = k.my.topics.useSuspenseInfiniteQuery() 48 | 49 | const { isRefetchingByUser, refetchByUser } = useRefreshByUser(refetch) 50 | 51 | const renderItem: ListRenderItem = useCallback( 52 | ({ item }) => , 53 | [] 54 | ) 55 | 56 | const flatedData = useMemo( 57 | () => uniqBy(data.pages.map(page => page.list).flat(), 'id'), 58 | [data.pages] 59 | ) 60 | 61 | const colorScheme = useAtomValue(colorSchemeAtom) 62 | 63 | const navbarHeight = useNavBarHeight() 64 | 65 | return ( 66 | 67 | 71 | 80 | } 81 | contentContainerStyle={{ 82 | paddingTop: navbarHeight, 83 | }} 84 | ItemSeparatorComponent={LineSeparator} 85 | renderItem={renderItem} 86 | onEndReached={() => { 87 | if (hasNextPage) { 88 | fetchNextPage() 89 | } 90 | }} 91 | onEndReachedThreshold={0.3} 92 | ListFooterComponent={ 93 | 94 | {isFetchingNextPage ? ( 95 | 96 | ) : null} 97 | 98 | } 99 | /> 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | ) 108 | } 109 | -------------------------------------------------------------------------------- /screens/MyNodesScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { useCallback } from 'react' 3 | import { 4 | FlatList, 5 | ListRenderItem, 6 | Text, 7 | TouchableOpacity, 8 | View, 9 | } from 'react-native' 10 | import { SafeAreaView } from 'react-native-safe-area-context' 11 | 12 | import Empty from '@/components/Empty' 13 | import LoadingIndicator from '@/components/LoadingIndicator' 14 | import NavBar, { useNavBarHeight } from '@/components/NavBar' 15 | import { 16 | FallbackComponent, 17 | withQuerySuspense, 18 | } from '@/components/QuerySuspense' 19 | import StyledBlurView from '@/components/StyledBlurView' 20 | import StyledImage from '@/components/StyledImage' 21 | import StyledRefreshControl from '@/components/StyledRefreshControl' 22 | import { colorSchemeAtom } from '@/jotai/themeAtom' 23 | import { uiAtom } from '@/jotai/uiAtom' 24 | import { navigation } from '@/navigation/navigationRef' 25 | import { Node, k } from '@/servicies' 26 | import tw from '@/utils/tw' 27 | import { useRefreshByUser } from '@/utils/useRefreshByUser' 28 | 29 | export default withQuerySuspense(MyNodesScreen, { 30 | LoadingComponent: () => ( 31 | 32 | 33 | 34 | 35 | ), 36 | fallbackRender: props => ( 37 | 38 | 39 | 40 | 41 | ), 42 | }) 43 | 44 | const ITEM_HEIGHT = 88 45 | 46 | function MyNodesScreen() { 47 | const { data: myNodes, refetch } = k.my.nodes.useSuspenseQuery() 48 | 49 | const { isRefetchingByUser, refetchByUser } = useRefreshByUser(refetch) 50 | 51 | const { colors, fontSize } = useAtomValue(uiAtom) 52 | 53 | const renderItem: ListRenderItem = useCallback( 54 | ({ item: node }) => { 55 | return ( 56 | { 59 | navigation.navigate('NodeTopics', { name: node.name }) 60 | }} 61 | style={tw`w-1/4 py-1 items-center justify-around h-[${ITEM_HEIGHT}px]`} 62 | > 63 | 64 | 65 | 69 | {node.title} 70 | 71 | 72 | ) 73 | }, 74 | [colors, fontSize] 75 | ) 76 | 77 | const colorScheme = useAtomValue(colorSchemeAtom) 78 | 79 | const navbarHeight = useNavBarHeight() 80 | 81 | return ( 82 | 83 | 95 | } 96 | ListEmptyComponent={} 97 | ListFooterComponent={} 98 | getItemLayout={(_, itemIndex) => ({ 99 | length: ITEM_HEIGHT, 100 | offset: itemIndex * ITEM_HEIGHT, 101 | index: itemIndex, 102 | })} 103 | /> 104 | 105 | 106 | 107 | 108 | 109 | 110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /screens/SearchNodeScreen.tsx: -------------------------------------------------------------------------------- 1 | import { RouteProp, useRoute } from '@react-navigation/native' 2 | import { useAtomValue } from 'jotai' 3 | import { isString, upperCase } from 'lodash-es' 4 | import { useCallback, useRef, useState } from 'react' 5 | import { 6 | FlatList, 7 | ListRenderItem, 8 | Text, 9 | TextInput, 10 | TouchableOpacity, 11 | View, 12 | } from 'react-native' 13 | import { SafeAreaView } from 'react-native-safe-area-context' 14 | 15 | import Empty from '@/components/Empty' 16 | import NavBar, { NAV_BAR_HEIGHT } from '@/components/NavBar' 17 | import NodeItem from '@/components/NodeItem' 18 | import SearchBar from '@/components/SearchBar' 19 | import { colorSchemeAtom } from '@/jotai/themeAtom' 20 | import { uiAtom } from '@/jotai/uiAtom' 21 | import { navigation } from '@/navigation/navigationRef' 22 | import { Node, k } from '@/servicies' 23 | import { RootStackParamList } from '@/types' 24 | import tw from '@/utils/tw' 25 | 26 | export default function SearchNodeScreen() { 27 | const { params } = useRoute>() 28 | 29 | const [searchText, setSearchText] = useState('') 30 | 31 | const { data: matchNodes } = k.node.all.useQuery({ 32 | select: useCallback( 33 | (nodes: Node[]) => { 34 | return searchText 35 | ? nodes.filter(node => 36 | [ 37 | node.title, 38 | node.title_alternative, 39 | node.name, 40 | ...(node.aliases || []), 41 | ].some( 42 | text => 43 | isString(text) && 44 | upperCase(text).includes(upperCase(searchText)) 45 | ) 46 | ) 47 | : nodes 48 | }, 49 | [searchText] 50 | ), 51 | }) 52 | 53 | const renderNodeItem: ListRenderItem = useCallback( 54 | ({ item }) => ( 55 | { 59 | navigation.goBack() 60 | params.onPressNodeItem(item) 61 | }} 62 | /> 63 | ), 64 | [params] 65 | ) 66 | 67 | const colorScheme = useAtomValue(colorSchemeAtom) 68 | 69 | const { colors, fontSize } = useAtomValue(uiAtom) 70 | 71 | const inputRef = useRef(null) 72 | 73 | return ( 74 | 75 | { 82 | navigation.goBack() 83 | }} 84 | > 85 | 86 | 取消 87 | 88 | 89 | } 90 | > 91 | { 96 | setSearchText(text.trim()) 97 | }} 98 | autoFocus 99 | placeholder="搜索节点" 100 | /> 101 | 102 | 103 | } 106 | data={matchNodes} 107 | renderItem={renderNodeItem} 108 | ListEmptyComponent={} 109 | getItemLayout={(_, index) => ({ 110 | length: NAV_BAR_HEIGHT, 111 | offset: index * NAV_BAR_HEIGHT, 112 | index, 113 | })} 114 | onScrollBeginDrag={() => { 115 | inputRef.current?.blur() 116 | }} 117 | /> 118 | 119 | ) 120 | } 121 | -------------------------------------------------------------------------------- /components/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import { StatusBarStyle } from 'expo-status-bar' 2 | import { useAtomValue } from 'jotai' 3 | import { isArray } from 'lodash-es' 4 | import { ReactNode, isValidElement } from 'react' 5 | import { Platform, PressableProps, Text, View, ViewStyle } from 'react-native' 6 | import { useSafeAreaInsets } from 'react-native-safe-area-context' 7 | 8 | import { getUI, uiAtom } from '@/jotai/uiAtom' 9 | import { navigation } from '@/navigation/navigationRef' 10 | import tw from '@/utils/tw' 11 | import { useStatusBarStyle } from '@/utils/useStatusBarStyle' 12 | 13 | import IconButton from './IconButton' 14 | import { supportsBlurviewColors } from './StyledBlurView' 15 | 16 | export const NAV_BAR_HEIGHT = 53 17 | 18 | export default function NavBar({ 19 | children, 20 | style, 21 | title, 22 | tintColor = getUI().colors.foreground, 23 | statusBarStyle = 'auto', 24 | left = , 25 | right, 26 | hideSafeTop, 27 | disableStatusBarStyle = false, 28 | }: { 29 | children?: ReactNode 30 | style?: ViewStyle 31 | title?: ReactNode 32 | tintColor?: string 33 | statusBarStyle?: StatusBarStyle 34 | left?: ReactNode 35 | right?: ReactNode 36 | hideSafeTop?: boolean 37 | disableStatusBarStyle?: boolean 38 | }) { 39 | if (!disableStatusBarStyle) { 40 | // eslint-disable-next-line react-hooks/rules-of-hooks 41 | useStatusBarStyle(statusBarStyle) 42 | } 43 | 44 | const safeTop = useNavBarSafeTop(hideSafeTop) 45 | const { colors, fontSize } = useAtomValue(uiAtom) 46 | 47 | return ( 48 | 57 | 58 | {!!left && ( 59 | 60 | {left} 61 | 62 | )} 63 | 64 | 65 | {isArray(children) || isValidElement(children) ? ( 66 | children 67 | ) : ( 68 | 78 | {title} 79 | 80 | )} 81 | 82 | 83 | {right && ( 84 | 85 | {right} 86 | 87 | )} 88 | 89 | 90 | ) 91 | } 92 | 93 | export function BackButton({ 94 | tintColor, 95 | onPress, 96 | }: { 97 | tintColor?: string 98 | onPress?: PressableProps['onPress'] 99 | }) { 100 | const { colors } = useAtomValue(uiAtom) 101 | const color = tintColor || colors.foreground 102 | 103 | return ( 104 | { 106 | if (typeof onPress === 'function') { 107 | onPress(ev) 108 | return 109 | } 110 | 111 | if (navigation.canGoBack()) { 112 | navigation.goBack() 113 | } else { 114 | navigation.replace('Home') 115 | } 116 | }} 117 | name="arrow-left" 118 | size={24} 119 | color={color} 120 | activeColor={color} 121 | /> 122 | ) 123 | } 124 | 125 | export function useNavBarHeight(hideSafeTop?: boolean) { 126 | return useNavBarSafeTop(hideSafeTop) + NAV_BAR_HEIGHT 127 | } 128 | 129 | function useNavBarSafeTop(hideSafeTop?: boolean) { 130 | const safeAreaInsets = useSafeAreaInsets() 131 | return !hideSafeTop || Platform.OS === 'android' ? safeAreaInsets.top : 0 132 | } 133 | -------------------------------------------------------------------------------- /components/StyledImage/BaseImage.tsx: -------------------------------------------------------------------------------- 1 | import { parseToRgba } from 'color2k' 2 | import { Image, ImageBackground, ImageProps, ImageSource } from 'expo-image' 3 | import { useAtomValue } from 'jotai' 4 | import { isEqual, isObject, memoize, pick } from 'lodash-es' 5 | import { useCallback, useEffect } from 'react' 6 | import { View, ViewStyle } from 'react-native' 7 | 8 | import { uiAtom } from '@/jotai/uiAtom' 9 | import { hasSize } from '@/utils/hasSize' 10 | import tw from '@/utils/tw' 11 | import { genBMPUri } from '@/utils/url' 12 | import useUpdate from '@/utils/useUpdate' 13 | 14 | import AnimatedImageOverlay, { getAnimatingImage } from './AnimatedImageOverlay' 15 | import BrokenImage from './BrokenImage' 16 | import { imageResults } from './helper' 17 | import { computeOptimalDispalySize } from './helper' 18 | 19 | export interface BaseImageProps extends ImageProps { 20 | containerWidth?: number 21 | } 22 | 23 | const genPlaceholder = memoize((color: string) => { 24 | const [r, g, b, a = 1] = parseToRgba(color) 25 | return genBMPUri(1, [b, g, r, parseInt(String(a * 255), 10)]) 26 | }) 27 | 28 | const failedImages = new Set<() => void>() 29 | 30 | export function BaseImage({ 31 | style, 32 | source, 33 | onLoad, 34 | onError, 35 | containerWidth, 36 | ...props 37 | }: BaseImageProps) { 38 | const { colors } = useAtomValue(uiAtom) 39 | const uri = (source as ImageSource).uri 40 | const result = imageResults.get(uri) 41 | const update = useUpdate() 42 | const hasPassedSize = hasSize(style) 43 | const imageProps: ImageProps = { 44 | ...props, 45 | source, 46 | onLoad: ev => { 47 | const nextImageResult = pick(ev.source, [ 48 | 'width', 49 | 'height', 50 | 'isAnimated', 51 | 'mediaType', 52 | ]) 53 | if (!isEqual(result, nextImageResult)) { 54 | imageResults.set(uri, nextImageResult) 55 | if (!hasPassedSize) update() 56 | } 57 | onLoad?.(ev) 58 | }, 59 | onError: err => { 60 | // TODO: This is a trick 61 | // Maybe fixed in next expo-image version 62 | if (!hasSize(result)) { 63 | imageResults.set(uri, 'error') 64 | update() 65 | } 66 | onError?.(err) 67 | }, 68 | placeholder: genPlaceholder(colors.neutral), 69 | placeholderContentFit: 'cover', 70 | style: tw.style( 71 | // Compute image size if style has no size 72 | !hasPassedSize && computeOptimalDispalySize(containerWidth, result), 73 | style as ViewStyle 74 | ), 75 | } 76 | 77 | const refetch = useCallback(() => { 78 | imageResults.set(uri, 'refetching') 79 | update() 80 | }, [update, uri]) 81 | 82 | useEffect(() => { 83 | if (result === 'error' && uri) { 84 | failedImages.add(refetch) 85 | } 86 | 87 | return () => { 88 | failedImages.delete(refetch) 89 | } 90 | }, [refetch, result, uri]) 91 | 92 | if (!uri) return 93 | 94 | if (result === 'error') { 95 | return ( 96 | { 98 | if (failedImages.size > 10) { 99 | failedImages.forEach(l => l()) 100 | } else { 101 | refetch() 102 | } 103 | }} 104 | style={style as any} 105 | /> 106 | ) 107 | } 108 | 109 | if (props.autoplay === false) { 110 | const isAnimating = uri === getAnimatingImage() 111 | const isMiniImage = 112 | isObject(result) && result.width < 50 && result.height < 50 113 | 114 | return ( 115 | 120 | {isObject(result) && !isMiniImage && result.isAnimated && ( 121 | 126 | )} 127 | 128 | ) 129 | } 130 | 131 | return 132 | } 133 | -------------------------------------------------------------------------------- /servicies/types.ts: -------------------------------------------------------------------------------- 1 | export interface PageData { 2 | page: number 3 | last_page: number 4 | list: T[] 5 | } 6 | 7 | export interface Profile { 8 | username: string 9 | avatar: string 10 | motto?: string 11 | gold?: number 12 | silver?: number 13 | bronze?: number 14 | my_nodes?: number 15 | my_topics?: number 16 | my_following?: number 17 | my_notification?: number 18 | once?: string 19 | } 20 | 21 | export interface Member { 22 | id?: number 23 | username: string 24 | website?: string 25 | twitter?: string 26 | psn?: string 27 | github?: string 28 | telegram?: string 29 | btc?: string 30 | location?: string 31 | tagline?: string 32 | bio?: string 33 | avatar?: string 34 | status?: string 35 | created?: string 36 | activity?: number 37 | online?: boolean 38 | motto?: string 39 | widgets?: { 40 | uri: string 41 | title: string 42 | link: string 43 | }[] 44 | gold?: number 45 | silver?: number 46 | bronze?: number 47 | company?: string 48 | title?: string 49 | overview?: string 50 | blocked?: boolean 51 | followed?: boolean 52 | cost?: string 53 | once?: string 54 | } 55 | 56 | export interface Node { 57 | avatar_large?: string 58 | name: string 59 | avatar_normal?: string 60 | title: string 61 | url?: string 62 | topics?: number 63 | footer?: string 64 | header?: string 65 | title_alternative?: string 66 | avatar_mini?: string 67 | stars?: number 68 | aliases?: string[] 69 | root?: boolean 70 | id?: number 71 | parent_node_name?: string 72 | last_modified?: number 73 | created?: number 74 | } 75 | 76 | export interface Supplement { 77 | content: string 78 | parsed_content?: string 79 | created: string 80 | } 81 | 82 | export interface Reply { 83 | id: number 84 | no: number 85 | content: string 86 | parsed_content?: string 87 | created: string 88 | member: { 89 | username: string 90 | avatar: string 91 | } 92 | thanks: number 93 | thanked: boolean 94 | mod?: boolean 95 | op?: boolean 96 | is_first_reply?: boolean 97 | is_last_reply?: boolean 98 | has_related_replies?: boolean 99 | reply_level: number 100 | is_merged?: boolean 101 | children: Reply[] 102 | } 103 | 104 | export interface Topic { 105 | node?: { 106 | name: string 107 | title: string 108 | } 109 | member?: Member 110 | created?: string 111 | last_reply_by?: string 112 | last_touched?: string 113 | title: string 114 | content: string 115 | parsed_content?: string 116 | last_modified?: string 117 | replies: Reply[] 118 | votes: number 119 | reply_count: number 120 | supplements: Supplement[] 121 | liked?: boolean 122 | ignored?: boolean 123 | once?: string 124 | thanked: boolean 125 | views: number 126 | likes: number 127 | thanks: number 128 | id: number 129 | pin_to_top?: boolean 130 | editable?: boolean 131 | appendable?: boolean 132 | } 133 | 134 | export interface Xna { 135 | node?: { 136 | name: string 137 | title: string 138 | } 139 | member?: Member 140 | created?: string 141 | last_touched?: string 142 | title: string 143 | content: string 144 | id: string 145 | pin_to_top?: boolean 146 | } 147 | 148 | export interface Notice { 149 | prev_action_text: string 150 | next_action_text: string 151 | created: string 152 | content: string | null 153 | member: Member 154 | topic: Topic 155 | id: number 156 | once: string 157 | } 158 | 159 | export interface Sov2exResult { 160 | took: number 161 | total: number 162 | hits: { 163 | _score?: any 164 | _index: string 165 | _type: string 166 | _id: string 167 | sort: any[] 168 | highlight: { 169 | 'reply_list.content': string[] 170 | title: string[] 171 | content: string[] 172 | } 173 | _source: { 174 | node: number 175 | replies: number 176 | created: Date 177 | member: string 178 | id: number 179 | title: string 180 | content: string 181 | } 182 | }[] 183 | timed_out: boolean 184 | from: number 185 | size: number 186 | } 187 | -------------------------------------------------------------------------------- /screens/WebviewScreen.tsx: -------------------------------------------------------------------------------- 1 | import { RouteProp, useRoute } from '@react-navigation/native' 2 | import { useAtomValue } from 'jotai' 3 | import { useEffect, useRef, useState } from 'react' 4 | import { BackHandler, Platform, View } from 'react-native' 5 | import WebView from 'react-native-webview' 6 | 7 | import IconButton from '@/components/IconButton' 8 | import LoadingIndicator from '@/components/LoadingIndicator' 9 | import NavBar from '@/components/NavBar' 10 | import StyledButton from '@/components/StyledButton' 11 | import { uiAtom } from '@/jotai/uiAtom' 12 | import { navigation } from '@/navigation/navigationRef' 13 | import { RootStackParamList } from '@/types' 14 | import tw from '@/utils/tw' 15 | import { openURL } from '@/utils/url' 16 | 17 | const getTitleScript = ` 18 | let _documentTitle = document.title; 19 | window.ReactNativeWebView.postMessage(_documentTitle) 20 | Object.defineProperty(document, 'title', { 21 | set (val) { 22 | _documentTitle = val 23 | window.ReactNativeWebView.postMessage(_documentTitle) 24 | }, 25 | get () { 26 | return _documentTitle 27 | } 28 | }); 29 | ` 30 | 31 | export default function WebviewScreen() { 32 | const { params } = useRoute>() 33 | 34 | const [isLoading, setIsLoading] = useState(true) 35 | 36 | const webViewRef = useRef(null) 37 | 38 | const [title, setTitle] = useState('') 39 | 40 | const canGoBackRef = useRef(false) 41 | 42 | const urlRef = useRef(params.url) 43 | 44 | useEffect(() => { 45 | if (Platform.OS === 'android') { 46 | const handlebackpressed = () => { 47 | if (canGoBackRef.current) { 48 | webViewRef.current?.goBack?.() 49 | } else { 50 | navigation.goBack() 51 | } 52 | return true 53 | } 54 | 55 | const subscription = BackHandler.addEventListener( 56 | 'hardwareBackPress', 57 | handlebackpressed 58 | ) 59 | 60 | return () => { 61 | subscription.remove() 62 | } 63 | } 64 | }, []) // initialize only once 65 | 66 | const { colors } = useAtomValue(uiAtom) 67 | 68 | return ( 69 | 70 | { 76 | navigation.goBack() 77 | }} 78 | name="close" 79 | size={24} 80 | color={colors.foreground} 81 | activeColor={colors.foreground} 82 | /> 83 | } 84 | right={ 85 | { 88 | openURL(urlRef.current) 89 | }} 90 | > 91 | 浏览器打开 92 | 93 | } 94 | /> 95 | 96 | { 101 | setIsLoading(false) 102 | }} 103 | onError={() => { 104 | setIsLoading(false) 105 | }} 106 | onNavigationStateChange={ev => { 107 | canGoBackRef.current = ev.canGoBack 108 | urlRef.current = ev.url 109 | setTitle(ev.title) 110 | }} 111 | source={{ uri: params.url }} 112 | javaScriptEnabled={true} 113 | domStorageEnabled={true} 114 | decelerationRate={0.998} 115 | sharedCookiesEnabled={true} 116 | startInLoadingState={true} 117 | scalesPageToFit={true} 118 | allowsBackForwardNavigationGestures 119 | injectedJavaScript={getTitleScript} 120 | onMessage={({ nativeEvent }) => { 121 | setTitle(nativeEvent.data) 122 | }} 123 | renderLoading={() => ( 124 | 127 | )} 128 | /> 129 | 130 | ) 131 | } 132 | -------------------------------------------------------------------------------- /screens/ConfigureDomainScreen.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from '@hookform/resolvers/zod' 2 | import { useAtom, useAtomValue } from 'jotai' 3 | import { useForm } from 'react-hook-form' 4 | import { ScrollView, Text, View } from 'react-native' 5 | import Toast from 'react-native-toast-message' 6 | import { z } from 'zod' 7 | 8 | import FormControl from '@/components/FormControl' 9 | import NavBar, { useNavBarHeight } from '@/components/NavBar' 10 | import { withQuerySuspense } from '@/components/QuerySuspense' 11 | import StyledBlurView from '@/components/StyledBlurView' 12 | import StyledButton from '@/components/StyledButton' 13 | import StyledTextInput from '@/components/StyledTextInput' 14 | import { baseUrlAtom, v2exURL } from '@/jotai/baseUrlAtom' 15 | import { uiAtom } from '@/jotai/uiAtom' 16 | import { navigation } from '@/navigation/navigationRef' 17 | import tw from '@/utils/tw' 18 | import { isValidURL } from '@/utils/url' 19 | import { stripString } from '@/utils/zodHelper' 20 | 21 | export default withQuerySuspense(ConfigureDomainScreen) 22 | 23 | const configureDomainScheme = z.object({ 24 | baseURL: z.preprocess( 25 | stripString, 26 | z 27 | .string() 28 | .trim() 29 | .refine(val => isValidURL(val), { 30 | message: 'Invalid URL', 31 | }) 32 | ), 33 | }) 34 | 35 | function ConfigureDomainScreen() { 36 | const navbarHeight = useNavBarHeight() 37 | 38 | const [baseURL, setBaseURL] = useAtom(baseUrlAtom) 39 | 40 | const { control, handleSubmit, setValue } = useForm< 41 | z.infer 42 | >({ 43 | resolver: zodResolver(configureDomainScheme), 44 | defaultValues: { 45 | baseURL, 46 | }, 47 | }) 48 | 49 | const { colors, fontSize } = useAtomValue(uiAtom) 50 | 51 | return ( 52 | 53 | 58 | 59 | 60 | 如果你因为一些原因无法访问v2ex的域名,你可以选择配置调用 API 61 | 的域名,或 62 | { 65 | navigation.navigate('TopicDetail', { id: 18591 }) 66 | }} 67 | > 68 | 手动选择离你最近的 V2EX 服务器 69 | 70 | ,支持协议+ip+端口。 71 | 72 | 73 | ( 77 | 85 | )} 86 | /> 87 | 88 | { 92 | setBaseURL(values.baseURL) 93 | navigation.goBack() 94 | })} 95 | > 96 | {'保存'} 97 | 98 | 99 | 100 | 101 | 102 | 103 | { 110 | setBaseURL(v2exURL) 111 | setValue('baseURL', v2exURL) 112 | Toast.show({ 113 | type: 'success', 114 | text1: `重置成功`, 115 | }) 116 | }} 117 | > 118 | 重置 119 | 120 | } 121 | /> 122 | 123 | 124 | ) 125 | } 126 | -------------------------------------------------------------------------------- /App.tsx: -------------------------------------------------------------------------------- 1 | import { ActionSheetProvider } from '@expo/react-native-action-sheet' 2 | import { QueryClientProvider } from '@tanstack/react-query' 3 | // import { enabledNetworkInspect } from './utils/enabledNetworkInspect' 4 | import * as SplashScreen from 'expo-splash-screen' 5 | import { StatusBar } from 'expo-status-bar' 6 | import { Provider, useAtom, useAtomValue } from 'jotai' 7 | import { waitForAll } from 'jotai/utils' 8 | import { ReactElement, ReactNode, Suspense, useEffect } from 'react' 9 | import { LogBox } from 'react-native' 10 | import 'react-native-gesture-handler' 11 | import { SafeAreaProvider } from 'react-native-safe-area-context' 12 | import SystemNavigationBar from 'react-native-system-navigation-bar' 13 | import { useDeviceContext } from 'twrnc' 14 | 15 | import { AsyncStoragePersist } from './components/AsyncStoragePersist' 16 | import StyledImageViewer from './components/StyledImageViewer' 17 | import StyledToast from './components/StyledToast' 18 | import { baseUrlAtom } from './jotai/baseUrlAtom' 19 | import { deviceTypeAtom } from './jotai/deviceTypeAtom' 20 | import { enabledAutoCheckinAtom } from './jotai/enabledAutoCheckinAtom' 21 | import { enabledMsgPushAtom } from './jotai/enabledMsgPushAtom' 22 | import { enabledParseContentAtom } from './jotai/enabledParseContent' 23 | import { enabledWebviewAtom } from './jotai/enabledWebviewAtom' 24 | import { imageViewerAtom } from './jotai/imageViewerAtom' 25 | import { imgurConfigAtom } from './jotai/imgurConfigAtom' 26 | import { profileAtom } from './jotai/profileAtom' 27 | import { searchHistoryAtom } from './jotai/searchHistoryAtom' 28 | import { sov2exArgsAtom } from './jotai/sov2exArgsAtom' 29 | import { store } from './jotai/store' 30 | import { colorSchemeAtom } from './jotai/themeAtom' 31 | import { topicDraftAtom } from './jotai/topicDraftAtom' 32 | import { colorsAtom, fontScaleAtom, themeNameAtom } from './jotai/uiAtom' 33 | import Navigation from './navigation' 34 | import { k } from './servicies' 35 | import './utils/dayjsPlugins' 36 | import { queryClient } from './utils/query' 37 | import tw from './utils/tw' 38 | 39 | SplashScreen.preventAutoHideAsync() 40 | 41 | LogBox.ignoreLogs([ 42 | 'Non-serializable values were found in the navigation state', 43 | 'Sending', 44 | ]) 45 | 46 | // enabledNetworkInspect() 47 | 48 | export default function App() { 49 | useEffect(() => { 50 | SystemNavigationBar.setNavigationColor('transparent') // use this 51 | }, []) 52 | 53 | return ( 54 | 55 | store}> 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | ) 73 | } 74 | 75 | function AppInitializer({ children }: { children: ReactNode }) { 76 | const [profile, enabledAutoCheckin, colorScheme] = useAtomValue( 77 | waitForAll([ 78 | profileAtom, 79 | enabledAutoCheckinAtom, 80 | colorSchemeAtom, 81 | enabledMsgPushAtom, 82 | fontScaleAtom, 83 | enabledParseContentAtom, 84 | imgurConfigAtom, 85 | topicDraftAtom, 86 | deviceTypeAtom, 87 | sov2exArgsAtom, 88 | baseUrlAtom, 89 | colorsAtom, 90 | themeNameAtom, 91 | enabledWebviewAtom, 92 | searchHistoryAtom, 93 | ]) 94 | ) 95 | 96 | useDeviceContext(tw, { 97 | observeDeviceColorSchemeChanges: false, 98 | initialColorScheme: colorScheme, 99 | }) 100 | 101 | k.node.all.useQuery() 102 | k.member.checkin.useQuery({ 103 | enabled: !!profile && enabledAutoCheckin, 104 | }) 105 | 106 | return children as ReactElement 107 | } 108 | 109 | function GlobalImageViewer() { 110 | const [imageViewer, setImageViewer] = useAtom(imageViewerAtom) 111 | return ( 112 | 115 | setImageViewer({ visible: false, index: 0, imageUrls: [] }) 116 | } 117 | /> 118 | ) 119 | } 120 | -------------------------------------------------------------------------------- /terms-and-conditions_en.md: -------------------------------------------------------------------------------- 1 | **Terms & Conditions** 2 | 3 | By downloading or using the app, these terms will automatically apply to you – you should make sure therefore that you read them carefully before using the app. You’re not allowed to copy or modify the app, any part of the app, or our trademarks in any way. You’re not allowed to attempt to extract the source code of the app, and you also shouldn’t try to translate the app into other languages or make derivative versions. The app itself, and all the trademarks, copyright, database rights, and other intellectual property rights related to it, still belong to XuanLiao. 4 | 5 | XuanLiao is committed to ensuring that the app is as useful and efficient as possible. For that reason, we reserve the right to make changes to the app or to charge for its services, at any time and for any reason. We will never charge you for the app or its services without making it very clear to you exactly what you’re paying for. 6 | 7 | The V2Fun app stores and processes personal data that you have provided to us, to provide my Service. It’s your responsibility to keep your phone and access to the app secure. We therefore recommend that you do not jailbreak or root your phone, which is the process of removing software restrictions and limitations imposed by the official operating system of your device. It could make your phone vulnerable to malware/viruses/malicious programs, compromise your phone’s security features and it could mean that the V2Fun app won’t work properly or at all. 8 | 9 | You should be aware that there are certain things that XuanLiao will not take responsibility for. Certain functions of the app will require the app to have an active internet connection. The connection can be Wi-Fi or provided by your mobile network provider, but XuanLiao cannot take responsibility for the app not working at full functionality if you don’t have access to Wi-Fi, and you don’t have any of your data allowance left. 10 | 11 | If you’re using the app outside of an area with Wi-Fi, you should remember that the terms of the agreement with your mobile network provider will still apply. As a result, you may be charged by your mobile provider for the cost of data for the duration of the connection while accessing the app, or other third-party charges. In using the app, you’re accepting responsibility for any such charges, including roaming data charges if you use the app outside of your home territory (i.e. region or country) without turning off data roaming. If you are not the bill payer for the device on which you’re using the app, please be aware that we assume that you have received permission from the bill payer for using the app. 12 | 13 | Along the same lines, XuanLiao cannot always take responsibility for the way you use the app i.e. You need to make sure that your device stays charged – if it runs out of battery and you can’t turn it on to avail the Service, XuanLiao cannot accept responsibility. 14 | 15 | With respect to XuanLiao’s responsibility for your use of the app, when you’re using the app, it’s important to bear in mind that although we endeavor to ensure that it is updated and correct at all times, we do rely on third parties to provide information to us so that we can make it available to you. XuanLiao accepts no liability for any loss, direct or indirect, you experience as a result of relying wholly on this functionality of the app. 16 | 17 | At some point, we may wish to update the app. The app is currently available on Android & iOS – the requirements for the both systems(and for any additional systems we decide to extend the availability of the app to) may change, and you’ll need to download the updates if you want to keep using the app. XuanLiao does not promise that it will always update the app so that it is relevant to you and/or works with the Android & iOS version that you have installed on your device. However, you promise to always accept updates to the application when offered to you, We may also wish to stop providing the app, and may terminate use of it at any time without giving notice of termination to you. Unless we tell you otherwise, upon any termination, (a) the rights and licenses granted to you in these terms will end; (b) you must stop using the app, and (if needed) delete it from your device. 18 | 19 | **Changes to This Terms and Conditions** 20 | 21 | I may update our Terms and Conditions from time to time. Thus, you are advised to review this page periodically for any changes. I will notify you of any changes by posting the new Terms and Conditions on this page. 22 | 23 | These terms and conditions are effective as of 2022-12-17 24 | -------------------------------------------------------------------------------- /servicies/auth.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio' 2 | import { isArray } from 'lodash-es' 3 | import { router } from 'react-query-kit' 4 | 5 | import { deletedNamesAtom } from '@/jotai/deletedNamesAtom' 6 | import { store } from '@/jotai/store' 7 | import { getCookie } from '@/utils/cookie' 8 | import { BizError, request } from '@/utils/request' 9 | import { paramsSerializer } from '@/utils/request/paramsSerializer' 10 | import { sleep } from '@/utils/sleep' 11 | import { getBaseURL } from '@/utils/url' 12 | 13 | import { isLogined } from './helper' 14 | 15 | export const authRouter = router(`auth`, { 16 | signout: router.mutation({ 17 | mutationFn: async ({ once }: { once: string }) => { 18 | const { data } = await request.get(`/signout?once=${once}`, { 19 | responseType: 'text', 20 | }) 21 | const $ = load(data) 22 | 23 | if (isLogined($)) { 24 | return Promise.reject(new Error('Failed to logout')) 25 | } 26 | }, 27 | }), 28 | 29 | signinInfo: router.query({ 30 | fetcher: async (_, { signal }) => { 31 | const { data } = await request.get(`/signin`, { 32 | responseType: 'text', 33 | signal, 34 | }) 35 | const $ = load(data) 36 | 37 | const captcha = $('#captcha-image').attr('src') 38 | 39 | return { 40 | is_limit: !captcha, 41 | captcha: `${captcha}?now=${Date.now()}`, 42 | once: $( 43 | '#Main > div.box > div.cell > form > table > tbody > tr:nth-child(4) > td:nth-child(2) > input[type=hidden]:nth-child(1)' 44 | ).attr('value'), 45 | username_hash: $( 46 | '#Main > div.box > div.cell > form > table > tbody > tr:nth-child(1) > td:nth-child(2) > input' 47 | ).attr('name'), 48 | password_hash: $( 49 | '#Main > div.box > div.cell > form > table > tbody > tr:nth-child(2) > td:nth-child(2) > input' 50 | ).attr('name'), 51 | code_hash: $( 52 | '#Main > div.box > div.cell > form > table > tbody > tr:nth-child(3) > td:nth-child(2) > input' 53 | ).attr('name'), 54 | cookie: await getCookie(), 55 | } 56 | }, 57 | gcTime: 0, 58 | staleTime: 0, 59 | }), 60 | 61 | signin: router.mutation({ 62 | mutationFn: async ({ 63 | username, 64 | ...args 65 | }: Record): Promise<{ 66 | '2fa'?: boolean 67 | once?: string 68 | }> => { 69 | if (await store.get(deletedNamesAtom)?.includes(username)) { 70 | await sleep(1000) 71 | return Promise.reject(new BizError('该帐号已注销')) 72 | } 73 | 74 | const { data } = await request.post('/signin', paramsSerializer(args), { 75 | headers: { 76 | 'content-type': 'application/x-www-form-urlencoded', 77 | Referer: `${getBaseURL()}/signin`, 78 | origin: getBaseURL(), 79 | }, 80 | }) 81 | 82 | const $ = load(data) 83 | 84 | if ($('#otp_code').length) { 85 | return { 86 | '2fa': true, 87 | once: $("input[name='once']").attr('value'), 88 | } 89 | } 90 | 91 | const problem = $(`#Main > div.box > div.problem > ul > li`) 92 | .eq(0) 93 | .text() 94 | .trim() 95 | 96 | if (isLogined($) && !problem) { 97 | return {} 98 | } 99 | 100 | return Promise.reject( 101 | new BizError( 102 | $('#captcha-image').attr('src') 103 | ? '登录失败' 104 | : '由于当前 IP 在短时间内的登录尝试次数太多,目前暂时不能继续尝试。' 105 | ) 106 | ) 107 | }, 108 | }), 109 | 110 | twoStepSignin: router.mutation({ 111 | mutationFn: async (args: { 112 | code: string 113 | once: string 114 | }): Promise => { 115 | const { headers, data } = await request.post( 116 | '/2fa', 117 | paramsSerializer(args), 118 | { 119 | headers: { 120 | 'content-type': 'application/x-www-form-urlencoded', 121 | Referer: `${getBaseURL()}/2fa`, 122 | origin: getBaseURL(), 123 | }, 124 | } 125 | ) 126 | 127 | const $ = load(data) 128 | 129 | const problem = $(`#Main > div.box > div.problem > ul > li`) 130 | .eq(0) 131 | .text() 132 | .trim() 133 | 134 | if (isLogined($) && !problem) { 135 | return isArray(headers['set-cookie']) 136 | ? headers['set-cookie'].join(';') 137 | : '' 138 | } 139 | 140 | return Promise.reject(new Error(`${problem || '登录失败'}`)) 141 | }, 142 | }), 143 | }) 144 | -------------------------------------------------------------------------------- /privacy-policy_en.md: -------------------------------------------------------------------------------- 1 | **Privacy Policy** 2 | 3 | XuanLiao built the V2Fun app as a Free app. This SERVICE is provided by XuanLiao at no cost and is intended for use as is. 4 | 5 | This page is used to inform visitors regarding my policies with the collection, use, and disclosure of Personal Information if anyone decided to use my Service. 6 | 7 | If you choose to use my Service, then you agree to the collection and use of information in relation to this policy. The Personal Information that I collect is used for providing and improving the Service. I will not use or share your information with anyone except as described in this Privacy Policy. 8 | 9 | The terms used in this Privacy Policy have the same meanings as in our Terms and Conditions, which are accessible at V2Fun unless otherwise defined in this Privacy Policy. 10 | 11 | **Information Collection and Use** 12 | 13 | For a better experience, while using our Service, I may require you to provide us with certain personally identifiable information. The information that I request will be retained on your device and is not collected by me in any way. 14 | 15 | **Log Data** 16 | 17 | I want to inform you that whenever you use my Service, in a case of an error in the app I collect data and information (through third-party products) on your phone called Log Data. This Log Data may include information such as your device Internet Protocol (“IP”) address, device name, operating system version, the configuration of the app when utilizing my Service, the time and date of your use of the Service, and other statistics. 18 | 19 | **Cookies** 20 | 21 | Cookies are files with a small amount of data that are commonly used as anonymous unique identifiers. These are sent to your browser from the websites that you visit and are stored on your device's internal memory. 22 | 23 | This Service does not use these “cookies” explicitly. However, the app may use third-party code and libraries that use “cookies” to collect information and improve their services. You have the option to either accept or refuse these cookies and know when a cookie is being sent to your device. If you choose to refuse our cookies, you may not be able to use some portions of this Service. 24 | 25 | **Service Providers** 26 | 27 | I may employ third-party companies and individuals due to the following reasons: 28 | 29 | - To facilitate our Service; 30 | - To provide the Service on our behalf; 31 | - To perform Service-related services; or 32 | - To assist us in analyzing how our Service is used. 33 | 34 | I want to inform users of this Service that these third parties have access to their Personal Information. The reason is to perform the tasks assigned to them on our behalf. However, they are obligated not to disclose or use the information for any other purpose. 35 | 36 | **Security** 37 | 38 | I value your trust in providing us your Personal Information, thus we are striving to use commercially acceptable means of protecting it. But remember that no method of transmission over the internet, or method of electronic storage is 100% secure and reliable, and I cannot guarantee its absolute security. 39 | 40 | **Links to Other Sites** 41 | 42 | This Service may contain links to other sites. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by me. Therefore, I strongly advise you to review the Privacy Policy of these websites. I have no control over and assume no responsibility for the content, privacy policies, or practices of any third-party sites or services. 43 | 44 | **Children’s Privacy** 45 | 46 | I do not knowingly collect personally identifiable information from children. I encourage all children to never submit any personally identifiable information through the Application and/or Services. I encourage parents and legal guardians to monitor their children's Internet usage and to help enforce this Policy by instructing their children never to provide personally identifiable information through the Application and/or Services without their permission. If you have reason to believe that a child has provided personally identifiable information to us through the Application and/or Services, please contact us. You must also be at least 16 years of age to consent to the processing of your personally identifiable information in your country (in some countries we may allow your parent or guardian to do so on your behalf). 47 | 48 | **Changes to This Privacy Policy** 49 | 50 | I may update our Privacy Policy from time to time. Thus, you are advised to review this page periodically for any changes. I will notify you of any changes by posting the new Privacy Policy on this page. 51 | 52 | This policy is effective as of 2022-12-17 53 | -------------------------------------------------------------------------------- /screens/NavNodesScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai' 2 | import { useCallback, useState } from 'react' 3 | import { 4 | FlatList, 5 | ListRenderItem, 6 | Pressable, 7 | ScrollView, 8 | Text, 9 | TouchableOpacity, 10 | View, 11 | } from 'react-native' 12 | import { SafeAreaView } from 'react-native-safe-area-context' 13 | 14 | import NavBar, { NAV_BAR_HEIGHT, useNavBarHeight } from '@/components/NavBar' 15 | import { withQuerySuspense } from '@/components/QuerySuspense' 16 | import StyledBlurView from '@/components/StyledBlurView' 17 | import StyledImage from '@/components/StyledImage' 18 | import { navNodesAtom } from '@/jotai/navNodesAtom' 19 | import { uiAtom } from '@/jotai/uiAtom' 20 | import { navigation } from '@/navigation/navigationRef' 21 | import { Node, k } from '@/servicies' 22 | import tw from '@/utils/tw' 23 | 24 | export default withQuerySuspense(NavNodesScreen, { 25 | LoadingComponent: () => null, 26 | }) 27 | 28 | const ITEM_HEIGHT = 88 29 | 30 | function NavNodesScreen() { 31 | const navNodes = useAtomValue(navNodesAtom) 32 | 33 | const { data: routes = [] } = k.node.all.useQuery({ 34 | select: nodes => { 35 | const nodeMap: Record = Object.fromEntries( 36 | nodes.map(node => [node.name, node]) 37 | ) 38 | return [ 39 | ...navNodes.map(node => ({ 40 | title: node.title, 41 | key: node.title, 42 | nodes: node.nodeNames.map(name => nodeMap[name]).filter(Boolean), 43 | })), 44 | { 45 | title: '全部节点', 46 | key: 'all', 47 | nodes, 48 | }, 49 | ] 50 | }, 51 | }) 52 | 53 | const [index, setIndex] = useState(0) 54 | 55 | const navbarHeight = useNavBarHeight() 56 | 57 | const { colors, fontSize } = useAtomValue(uiAtom) 58 | 59 | const renderItem: ListRenderItem = useCallback( 60 | ({ item: node }) => { 61 | return ( 62 | { 65 | navigation.navigate('NodeTopics', { name: node.name }) 66 | }} 67 | style={tw`w-1/3 py-1 items-center justify-around h-[${ITEM_HEIGHT}px]`} 68 | > 69 | 70 | 71 | 75 | {node.title} 76 | 77 | 78 | ) 79 | }, 80 | [colors, fontSize] 81 | ) 82 | 83 | return ( 84 | 85 | 86 | 92 | {routes.map((route, i) => ( 93 | setIndex(i)} 96 | style={({ pressed }) => 97 | tw.style( 98 | `h-[${NAV_BAR_HEIGHT}px] px-4 items-center justify-center`, 99 | pressed && `bg-[${colors.foreground}] bg-opacity-10` 100 | ) 101 | } 102 | > 103 | 111 | {route.title} 112 | 113 | 114 | ))} 115 | 116 | 117 | 118 | 119 | } 126 | getItemLayout={(_, itemIndex) => ({ 127 | length: ITEM_HEIGHT, 128 | offset: itemIndex * ITEM_HEIGHT, 129 | index: itemIndex, 130 | })} 131 | /> 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | ) 140 | } 141 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "V2Fun", 4 | "slug": "v2ex", 5 | "owner": "v2fun", 6 | "version": "1.8.8", 7 | "scheme": "v2fun", 8 | "icon": "./assets/icon.png", 9 | "userInterfaceStyle": "automatic", 10 | "splash": { 11 | "image": "./assets/splash.png", 12 | "resizeMode": "contain", 13 | "backgroundColor": "#000000" 14 | }, 15 | "updates": { 16 | "fallbackToCacheTimeout": 0, 17 | "url": "https://u.expo.dev/dd398bbd-e8f2-4519-a933-ec68a355980f" 18 | }, 19 | "assetBundlePatterns": ["**/*"], 20 | "ios": { 21 | "supportsTablet": true, 22 | "bundleIdentifier": "com.liaoliao666.v2ex", 23 | "buildNumber": "1.8.8.1", 24 | "entitlements": { 25 | "aps-environment": "development" 26 | }, 27 | "infoPlist": { 28 | "LSMinimumSystemVersion": "13.0.0", 29 | "ITSAppUsesNonExemptEncryption": false 30 | }, 31 | "privacyManifests": { 32 | "NSPrivacyAccessedAPITypes": [ 33 | { 34 | "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults", 35 | "NSPrivacyAccessedAPITypeReasons": ["CA92.1"] 36 | }, 37 | { 38 | "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp", 39 | "NSPrivacyAccessedAPITypeReasons": ["DDA9.1"] 40 | }, 41 | { 42 | "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryDiskSpace", 43 | "NSPrivacyAccessedAPITypeReasons": ["85F4.1"] 44 | }, 45 | { 46 | "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime", 47 | "NSPrivacyAccessedAPITypeReasons": ["35F9.1"] 48 | }, 49 | { 50 | "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults", 51 | "NSPrivacyAccessedAPITypeReasons": ["CA92.1"] 52 | }, 53 | { 54 | "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp", 55 | "NSPrivacyAccessedAPITypeReasons": ["DDA9.1"] 56 | }, 57 | { 58 | "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryDiskSpace", 59 | "NSPrivacyAccessedAPITypeReasons": ["85F4.1"] 60 | }, 61 | { 62 | "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime", 63 | "NSPrivacyAccessedAPITypeReasons": ["35F9.1"] 64 | } 65 | ] 66 | } 67 | }, 68 | "android": { 69 | "adaptiveIcon": { 70 | "foregroundImage": "./assets/adaptive-icon.png", 71 | "backgroundColor": "#000000" 72 | }, 73 | "package": "com.liaoliao666.v2ex" 74 | }, 75 | "web": { 76 | "favicon": "./assets/favicon.png" 77 | }, 78 | "extra": { 79 | "eas": { 80 | "projectId": "dd398bbd-e8f2-4519-a933-ec68a355980f" 81 | }, 82 | "expo-navigation-bar": { 83 | "position": "absolute", 84 | "backgroundColor": "#000000", 85 | "buttonStyle": "light" 86 | } 87 | }, 88 | "runtimeVersion": "1.8.7", 89 | "plugins": [ 90 | [ 91 | "expo-navigation-bar", 92 | { 93 | "position": "absolute", 94 | "backgroundColor": "#000000", 95 | "buttonStyle": "light" 96 | } 97 | ], 98 | [ 99 | "expo-build-properties", 100 | { 101 | "android": { 102 | "useLegacyPackaging": true, 103 | "enableProguardInReleaseBuilds": true, 104 | "enableShrinkResourcesInReleaseBuilds": true, 105 | "packagingOptions": { 106 | "exclude": ["lib/x86/**"] 107 | }, 108 | "manifestQueries": { 109 | "intent": [ 110 | { 111 | "action": "VIEW", 112 | "data": { 113 | "scheme": "http" 114 | } 115 | }, 116 | { 117 | "action": "VIEW", 118 | "data": { 119 | "scheme": "https" 120 | } 121 | }, 122 | { 123 | "action": "VIEW", 124 | "data": { 125 | "scheme": "geo" 126 | } 127 | }, 128 | { 129 | "action": "VIEW", 130 | "data": { 131 | "scheme": "google.navigation" 132 | } 133 | } 134 | ], 135 | "package": ["com.liaoliao666.v2ex"] 136 | } 137 | } 138 | } 139 | ] 140 | ], 141 | "experiments": { 142 | "reactCompiler": true 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /utils/request/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { CheerioAPI, load } from 'cheerio' 3 | import dayjs from 'dayjs' 4 | import { RESET } from 'jotai/utils' 5 | import { isInteger, isObjectLike } from 'lodash-es' 6 | import { isEqual } from 'lodash-es' 7 | import Toast from 'react-native-toast-message' 8 | import { isString } from 'twrnc/dist/esm/types' 9 | 10 | import { enabledMsgPushAtom } from '@/jotai/enabledMsgPushAtom' 11 | import { navNodesAtom } from '@/jotai/navNodesAtom' 12 | import { open503UrlTimeAtom } from '@/jotai/open503UrlTimeAtom' 13 | import { profileAtom } from '@/jotai/profileAtom' 14 | import { recentTopicsAtom } from '@/jotai/recentTopicsAtom' 15 | import { store } from '@/jotai/store' 16 | import { getCurrentRouteName, navigation } from '@/navigation/navigationRef' 17 | import { 18 | parseNavAtoms, 19 | parseProfile, 20 | parseRecentTopics, 21 | } from '@/servicies/helper' 22 | 23 | import { getBaseURL, openURL } from '../url' 24 | 25 | export class BizError extends Error { 26 | constructor(message: string) { 27 | super(message) 28 | this.name = 'BizError' 29 | } 30 | } 31 | 32 | export const request = axios.create({ 33 | baseURL: getBaseURL(), 34 | }) 35 | 36 | request.interceptors.request.use(config => { 37 | return { 38 | ...config, 39 | baseURL: getBaseURL(), 40 | } 41 | }) 42 | 43 | request.interceptors.response.use( 44 | response => { 45 | const { data } = response 46 | 47 | // handle json error 48 | if (isObjectLike(data) && data.success === false) { 49 | throw data.message 50 | ? new BizError(data.message) 51 | : new Error('Something went wrong') 52 | } 53 | 54 | const $ = isString(data) ? load(data) : undefined 55 | 56 | // handle html error 57 | if ($) { 58 | const problem = $(`#Main > div.box > div.problem > ul > li`) 59 | .eq(0) 60 | .text() 61 | .trim() 62 | 63 | if (problem) { 64 | throw new BizError(problem) 65 | } 66 | } 67 | 68 | // update store 69 | try { 70 | if ($) { 71 | updateProfile($) 72 | updateNavNodes($) 73 | updateRecentTopics($) 74 | } 75 | } catch (error) { 76 | // empty 77 | } 78 | 79 | return response 80 | }, 81 | error => { 82 | handle503Error(error) 83 | if (error instanceof Error && error.message.includes(`403`)) { 84 | const err = new Error('请检查你的代理设置') 85 | err.name = '请求失败' 86 | return Promise.reject(err) 87 | } 88 | return Promise.reject(error) 89 | } 90 | ) 91 | 92 | async function handle503Error(error: any) { 93 | try { 94 | if ( 95 | isObjectLike(error) && 96 | isObjectLike(error.config) && 97 | error.message.includes(`503`) && 98 | error.config.method === 'get' && 99 | !error.config.url.startsWith('http') 100 | ) { 101 | const open503UrlTime = await store.get(open503UrlTimeAtom) 102 | if (dayjs().diff(open503UrlTime, 'hour') > 8) { 103 | store.set(open503UrlTimeAtom, Date.now()) 104 | openURL(`${getBaseURL()}${error.config.url}`) 105 | } 106 | } 107 | } catch { 108 | // empty 109 | } 110 | } 111 | 112 | function updateProfile($: CheerioAPI) { 113 | const hasProfile = 114 | !!$('#Rightbar #money').length || 115 | !!$('#Rightbar #member-activity').length || 116 | !!$('#Rightbar .light-toggle').length 117 | if (hasProfile) { 118 | const newProfile = parseProfile($) 119 | 120 | store.set(profileAtom, prev => { 121 | if ( 122 | getCurrentRouteName() !== 'Home' && 123 | store.get(enabledMsgPushAtom) && 124 | newProfile.my_notification !== prev?.my_notification && 125 | isInteger(newProfile.my_notification) && 126 | newProfile.my_notification! > 0 127 | ) { 128 | Toast.show({ 129 | type: 'success', 130 | text1: `消息通知`, 131 | text2: `你有 ${newProfile.my_notification} 条未读消息`, 132 | onPress: () => { 133 | navigation.navigate('Notifications') 134 | Toast.hide() 135 | }, 136 | }) 137 | } 138 | 139 | return isEqual(newProfile, prev) ? prev : newProfile 140 | }) 141 | } else if ( 142 | $('#Top div.tools > a:nth-child(3)').attr('href')?.includes('signin') 143 | ) { 144 | store.set(profileAtom, RESET) 145 | } 146 | } 147 | 148 | function updateNavNodes($: CheerioAPI) { 149 | const $nodesBox = $(`#Main .box`).eq(1) 150 | const hasNavAtoms = $nodesBox.find('.fr a').eq(0).attr('href') === '/planes' 151 | if (!hasNavAtoms) return 152 | store.set(navNodesAtom, parseNavAtoms($)) 153 | } 154 | 155 | function updateRecentTopics($: CheerioAPI) { 156 | if ($(`#my-recent-topics`).length) { 157 | store.set(recentTopicsAtom, parseRecentTopics($)) 158 | } 159 | } 160 | --------------------------------------------------------------------------------