├── .npmrc ├── App.js ├── assets ├── icon.png ├── ios.png ├── android.png ├── favicon.png ├── splash.png ├── adaptive-icon.png └── icon.svg ├── src ├── screens │ ├── TopScreen.res │ ├── LatestScreen.res │ ├── DetailScreen.res │ ├── LoginScreen.res │ └── SettingsScreen.res ├── components │ ├── Loading.res │ ├── Nickname.res │ ├── Vote.res │ ├── CommentItem.res │ ├── Posts.res │ └── PostItem.res ├── navigators │ ├── SettingsStack.res │ ├── TopStack.res │ ├── LatestStack.res │ └── BottomTabs.res ├── Utils.res ├── Model.res ├── bindings │ ├── TableView.res │ └── Expo.res ├── main.res └── contexts │ ├── Theme.res │ └── Auth.res ├── jsconfig.json ├── .gitignore ├── babel.config.js ├── .github └── workflows │ └── style.yml ├── bsconfig.json ├── app.json ├── README.md ├── package.json └── LICENSE /.npmrc: -------------------------------------------------------------------------------- 1 | node-linker=hoisted 2 | -------------------------------------------------------------------------------- /App.js: -------------------------------------------------------------------------------- 1 | export { default } from "./lib/es6/src/main.js"; 2 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pd4d10/echojs-reader/HEAD/assets/icon.png -------------------------------------------------------------------------------- /assets/ios.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pd4d10/echojs-reader/HEAD/assets/ios.png -------------------------------------------------------------------------------- /assets/android.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pd4d10/echojs-reader/HEAD/assets/android.png -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pd4d10/echojs-reader/HEAD/assets/favicon.png -------------------------------------------------------------------------------- /assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pd4d10/echojs-reader/HEAD/assets/splash.png -------------------------------------------------------------------------------- /assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pd4d10/echojs-reader/HEAD/assets/adaptive-icon.png -------------------------------------------------------------------------------- /src/screens/TopScreen.res: -------------------------------------------------------------------------------- 1 | @react.component 2 | let make = (~navigation as _, ~route as _) => { 3 | 4 | } 5 | -------------------------------------------------------------------------------- /src/screens/LatestScreen.res: -------------------------------------------------------------------------------- 1 | @react.component 2 | let make = (~navigation as _, ~route as _) => { 3 | 4 | } 5 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "jsx": "react-native", 5 | "resolveJsonModule": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.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 | 13 | # macOS 14 | .DS_Store 15 | 16 | .yarn 17 | lib 18 | *.bs.js 19 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ["babel-preset-expo"], 5 | plugins: ["react-native-reanimated/plugin"], // https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/installation/ 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /.github/workflows/style.yml: -------------------------------------------------------------------------------- 1 | name: style 2 | on: 3 | push: 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | - uses: actions/setup-node@v3 10 | - run: corepack enable 11 | - run: pnpm install 12 | - run: pnpm rescript format -check 13 | -------------------------------------------------------------------------------- /src/components/Loading.res: -------------------------------------------------------------------------------- 1 | open ReactNative 2 | 3 | @react.component 4 | let make = (~style=?, ~size=?) => { 5 | let theme = Theme.context->React.useContext 6 | let color = switch Platform.os { 7 | | #android => theme->Option.map(theme => theme.colors.primary) 8 | | _ => None 9 | } 10 | 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/components/Nickname.res: -------------------------------------------------------------------------------- 1 | open ReactNative 2 | 3 | @react.component 4 | let make = (~name) => { 5 | { 8 | Expo.WebBrowser.openBrowserAsync(`https://echojs.com/user/${name}`, ())->ignore 9 | }}> 10 | {name->React.string} 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/navigators/SettingsStack.res: -------------------------------------------------------------------------------- 1 | module M = { 2 | type params 3 | } 4 | module Stack = ReactNavigation.NativeStack.Make(M) 5 | 6 | @react.component 7 | let make = (~navigation as _, ~route as _) => { 8 | 9 | 10 | 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/Utils.res: -------------------------------------------------------------------------------- 1 | let getHostFromUrl = url => { 2 | url 3 | ->String.replaceRegExp(Re.fromString("^.*?\/\/"), "") 4 | ->String.split("/") 5 | ->Array.get(0) 6 | ->Option.getWithDefault("") 7 | } 8 | 9 | @module("date-fns") 10 | external formatDistance: (. float, float) => string = "formatDistance" 11 | 12 | let timeAgo = timestamp => { 13 | formatDistance(. timestamp *. 1000., Date.now()) 14 | } 15 | -------------------------------------------------------------------------------- /src/navigators/TopStack.res: -------------------------------------------------------------------------------- 1 | module M = { 2 | type params 3 | } 4 | module Stack = ReactNavigation.NativeStack.Make(M) 5 | 6 | @react.component 7 | let make = (~navigation as _, ~route as _) => { 8 | 9 | 10 | 11 | 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/navigators/LatestStack.res: -------------------------------------------------------------------------------- 1 | module M = { 2 | type params 3 | } 4 | module Stack = ReactNavigation.NativeStack.Make(M) 5 | 6 | @react.component 7 | let make = (~navigation as _, ~route as _) => { 8 | 9 | 10 | 11 | 12 | 13 | } 14 | -------------------------------------------------------------------------------- /bsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "echojs-reader", 3 | "sources": { 4 | "dir": "src", 5 | "subdirs": true 6 | }, 7 | "package-specs": { 8 | "module": "es6" 9 | }, 10 | "jsx": { "version": 3 }, 11 | "bsc-flags": ["-open RescriptCore"], 12 | "ppx-flags": ["@greenlabs/ppx-spice/ppx"], 13 | "bs-dependencies": [ 14 | "@rescript/core", 15 | "@rescript/react", 16 | "rescript-react-native", 17 | "rescript-webapi", 18 | "rescript-react-navigation", 19 | "@rescript-react-native/async-storage", 20 | "@greenlabs/ppx-spice" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Vote.res: -------------------------------------------------------------------------------- 1 | open ReactNative 2 | 3 | @react.component 4 | let make = (~up, ~down, ~voted) => { 5 | let theme = Theme.context->React.useContext->Option.getExn 6 | 7 | 8 | 13 | {`▲ ${up->Int.toString}`->React.string} 14 | 15 | 20 | {`▼ ${down->Int.toString}`->React.string} 21 | 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/Model.res: -------------------------------------------------------------------------------- 1 | @spice 2 | type rec comment = { 3 | body: string, 4 | ctime: float, 5 | replies: array, 6 | up: int, 7 | username: string, 8 | down?: int, 9 | } 10 | 11 | @spice 12 | type post = { 13 | comments: string, 14 | ctime: string, 15 | down: string, 16 | id: string, 17 | title: string, 18 | up: string, 19 | url: string, 20 | username: string, 21 | voted?: string, // TODO: [#up | #down], 22 | del?: bool, 23 | } 24 | 25 | module Api = { 26 | @spice 27 | type login = { 28 | auth: string, 29 | apisecret: string, 30 | } 31 | 32 | @spice 33 | type comments = {comments: array} 34 | 35 | @spice 36 | type posts = {news: array} 37 | } 38 | -------------------------------------------------------------------------------- /src/bindings/TableView.res: -------------------------------------------------------------------------------- 1 | module TableView = { 2 | @module("react-native-tableview-simple") @react.component 3 | external make: (~children: React.element=?) => React.element = "TableView" 4 | } 5 | 6 | module Section = { 7 | @module("react-native-tableview-simple") @react.component 8 | external make: ( 9 | ~header: string=?, 10 | ~sectionTintColor: [#transparent]=?, 11 | ~children: React.element=?, 12 | ) => React.element = "Section" 13 | } 14 | 15 | module Cell = { 16 | @module("react-native-tableview-simple") @react.component 17 | external make: ( 18 | ~title: string=?, 19 | ~cellContentView: React.element=?, 20 | ~cellAccessoryView: React.element=?, 21 | ~accessory: [#Checkmark | #DisclosureIndicator]=?, 22 | ~accessoryColor: string=?, 23 | ~onPress: unit => unit=?, 24 | ~withSafeAreaView: bool=?, 25 | ) => React.element = "Cell" 26 | } 27 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "echojs-reader", 4 | "slug": "echojs-reader", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "userInterfaceStyle": "light", 9 | "splash": { 10 | "image": "./assets/splash.png", 11 | "resizeMode": "contain", 12 | "backgroundColor": "#ffffff" 13 | }, 14 | "updates": { 15 | "fallbackToCacheTimeout": 0 16 | }, 17 | "assetBundlePatterns": ["**/*"], 18 | "ios": { 19 | "supportsTablet": true, 20 | "bundleIdentifier": "com.pd4d10.LamerNews" 21 | }, 22 | "android": { 23 | "package": "io.github.pd4d10.echojsreader", 24 | "adaptiveIcon": { 25 | "foregroundImage": "./assets/adaptive-icon.png", 26 | "backgroundColor": "#FFFFFF" 27 | } 28 | }, 29 | "web": { 30 | "favicon": "./assets/favicon.png" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EchoJS Reader 2 | 3 | Open source [EchoJS](https://echojs.com/) reader built with React Native. 4 | 5 |

6 | 7 | Download on the App Store 8 | 9 | 10 | Get it on Google Play 11 | 12 |

13 | 14 | ## Installation 15 | 16 | Click badges above to install it from Apple App Store or Google Play. 17 | 18 | Android users can also download APK file from [release page](https://github.com/pd4d10/echojs-reader/releases) and install it manually. 19 | 20 | ## Screenshots 21 | 22 | iOSAndroid 23 | 24 | ## License 25 | 26 | Apache-2.0 27 | -------------------------------------------------------------------------------- /src/main.res: -------------------------------------------------------------------------------- 1 | module App = { 2 | @react.component 3 | let make = () => { 4 | let {colors} = Theme.context->React.useContext->Option.getExn 5 | 6 | 14 | Some(colors.headerAndroidBar) 18 | | _ => None 19 | }} 20 | /> 21 | 22 | 23 | } 24 | } 25 | 26 | @react.component 27 | let default = () => { 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "echojs-reader", 3 | "version": "1.1.0", 4 | "main": "node_modules/expo/AppEntry.js", 5 | "scripts": { 6 | "start": "expo start", 7 | "android": "expo start --android", 8 | "ios": "expo start --ios", 9 | "web": "expo start --web" 10 | }, 11 | "dependencies": { 12 | "@expo/react-native-action-sheet": "^4.0.1", 13 | "@react-native-async-storage/async-storage": "^1.18.0", 14 | "@react-navigation/bottom-tabs": "^6.5.7", 15 | "@react-navigation/native": "^6.1.6", 16 | "@react-navigation/native-stack": "^6.9.12", 17 | "date-fns": "^2.29.3", 18 | "expo": "^48.0.9", 19 | "expo-status-bar": "^1.4.4", 20 | "expo-web-browser": "^12.1.1", 21 | "react": "^18.2.0", 22 | "react-native": "^0.71.4", 23 | "react-native-gesture-handler": "^2.9.0", 24 | "react-native-reanimated": "^3.0.2", 25 | "react-native-safe-area-context": "4.5.0", 26 | "react-native-screens": "^3.20.0", 27 | "react-native-tableview-simple": "^4.4.0" 28 | }, 29 | "devDependencies": { 30 | "@babel/core": "^7.21.3", 31 | "@greenlabs/ppx-spice": "^0.1.12", 32 | "@rescript-react-native/async-storage": "^1.6.3", 33 | "@rescript/core": "^0.2.0", 34 | "@rescript/react": "^0.11.0", 35 | "rescript": "^10.1.3", 36 | "rescript-react-native": "^0.70.0", 37 | "rescript-react-navigation": "^6.0.3", 38 | "rescript-webapi": "^0.7.0" 39 | }, 40 | "private": true, 41 | "packageManager": "pnpm@8.0.0" 42 | } 43 | -------------------------------------------------------------------------------- /src/navigators/BottomTabs.res: -------------------------------------------------------------------------------- 1 | module M = { 2 | type params 3 | } 4 | module Tab = ReactNavigation.BottomTabs.Make(M) 5 | 6 | @react.component 7 | let make = () => { 8 | let {colors} = Theme.context->React.useContext->Option.getExn 9 | 10 | let getIconColor = focused => { 11 | focused ? colors.primary : colors.tabInactive 12 | } 13 | 14 | 16 | Tab.options( 17 | ~headerShown=false, 18 | ~tabBarActiveTintColor=colors.primary, 19 | ~tabBarInactiveTintColor=colors.tabInactive, 20 | (), 21 | )}> 22 | 26 | Tab.options( 27 | ~tabBarIcon=({focused}) => 28 | , 29 | (), 30 | )} 31 | /> 32 | 36 | Tab.options( 37 | ~tabBarIcon=({focused}) => 38 | , 39 | (), 40 | )} 41 | /> 42 | 46 | Tab.options( 47 | ~tabBarIcon=({focused}) => 48 | , 49 | (), 50 | )} 51 | /> 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/components/CommentItem.res: -------------------------------------------------------------------------------- 1 | open ReactNative 2 | 3 | @react.component 4 | let rec make = (~item: Model.comment, ~level) => { 5 | let marginLeft = level->Float.fromInt *. 20. 6 | let theme = Theme.context->React.useContext->Option.getExn 7 | 8 | <> 9 | Style.dp, 13 | ~marginLeft=marginLeft->Style.dp, 14 | // borderTopColor: colors.contentBorder, 15 | // borderTopWidth: this.props.index === 0 ? 0 : 1, 16 | (), 17 | )}> 18 | 19 | Style.dp, ())}> 21 | 22 | {` | ${item.ctime->Utils.timeAgo} ago`->React.string} 23 | 24 | 25 | {item.body->React.string} 26 | 27 | 28 | Style.dp, 32 | ~marginTop=2.->Style.dp, 33 | ~paddingLeft=10.->Style.dp, 34 | (), 35 | )}> 36 | { 37 | let up = item.up 38 | let down = item.down->Option.getWithDefault(0) 39 | 40 | } 41 | 42 | 43 | {item.replies 44 | ->Array.map(reply => 45 | make->React.createElement({ 46 | // "key": reply.ctime + reply.username, 47 | "item": reply, 48 | "level": level + 1, 49 | }) 50 | ) 51 | ->React.array} 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/screens/DetailScreen.res: -------------------------------------------------------------------------------- 1 | open ReactNative 2 | 3 | @react.component 4 | let make = (~navigation as _, ~route as _) => { 5 | let route = ReactNavigation.Native.useRoute()->Nullable.toOption->Option.getExn 6 | let params: Model.post = route.params->Option.getExn 7 | 8 | let theme = Theme.context->React.useContext->Option.getExn 9 | let auth = Auth.context->React.useContext 10 | 11 | let (loading, setLoading) = React.useState(_ => false) 12 | let (comments, setComments) = React.useState(_ => []) 13 | 14 | React.useEffect0(() => { 15 | let init = async () => { 16 | try { 17 | setLoading(_ => true) 18 | // let id = "22273" 19 | let id = params.id 20 | let json = await auth->Auth.fetch(`/getcomments/${id}`, #get) 21 | let {comments} = json->Model.Api.comments_decode->Result.getExn 22 | setComments(_ => 23 | comments->Array.sort((a, b) => a.ctime->Float.toInt - b.ctime->Float.toInt) 24 | ) 25 | setLoading(_ => false) 26 | } catch { 27 | | Exn.Error(obj) => setLoading(_ => false) 28 | // TODO: 29 | } 30 | } 31 | 32 | init()->ignore 33 | 34 | None 35 | }) 36 | 37 | Style.dp, 41 | (), 42 | )}> 43 | { 47 | () 48 | }} 49 | /> 50 | 57 | {if loading { 58 | Style.dp, ())} /> 59 | } else { 60 | comments 61 | ->Array.map(comment => { 62 | Float.toString ++ comment.username} item=comment level=0 /> 63 | }) 64 | ->React.array 65 | }} 66 | 67 | } 68 | -------------------------------------------------------------------------------- /src/bindings/Expo.res: -------------------------------------------------------------------------------- 1 | module WebBrowser = { 2 | type openOptions = { 3 | controlsColor?: string, 4 | dismissButtonStyle?: [#done | #close | #cancel], 5 | toolbarColor?: string, 6 | } 7 | 8 | type result = {@as("type") type_: [#cancel | #dismiss | #locked | #opened]} 9 | 10 | @module("expo-web-browser") 11 | external openBrowserAsync: (string, ~browserParams: openOptions=?, unit) => promise = 12 | "openBrowserAsync" 13 | } 14 | 15 | module StatusBar = { 16 | type style = [#auto | #inverted | #light | #dark] 17 | 18 | @react.component @module("expo-status-bar") 19 | external make: (~style: style=?, ~backgroundColor: string=?) => React.element = "StatusBar" 20 | } 21 | 22 | module ActionSheet = { 23 | type options = { 24 | title?: string, 25 | options: array, 26 | cancelButtonIndex?: int, 27 | } 28 | 29 | type actionSheetProps = {showActionSheetWithOptions: (. options, ~callback: int => unit) => unit} 30 | 31 | @module("@expo/react-native-action-sheet") 32 | external useActionSheet: unit => actionSheetProps = "useActionSheet" 33 | 34 | module ActionSheetProvider = { 35 | @react.component @module("@expo/react-native-action-sheet") 36 | external make: (~children: React.element=?) => React.element = "ActionSheetProvider" 37 | } 38 | } 39 | 40 | module VectorIcons = { 41 | module Ionicons = { 42 | @react.component @module("@expo/vector-icons") 43 | external make: ( 44 | ~name: string=?, 45 | ~size: int=?, 46 | ~color: string=?, 47 | ~style: ReactNative.Style.t=?, 48 | unit, 49 | ) => React.element = "Ionicons" 50 | } 51 | module Entypo = { 52 | @react.component @module("@expo/vector-icons") 53 | external make: ( 54 | ~name: string=?, 55 | ~size: int=?, 56 | ~color: string=?, 57 | ~style: ReactNative.Style.t=?, 58 | unit, 59 | ) => React.element = "Entypo" 60 | } 61 | module MaterialCommunityIcons = { 62 | @react.component @module("@expo/vector-icons") 63 | external make: ( 64 | ~name: string=?, 65 | ~size: int=?, 66 | ~color: string=?, 67 | ~style: ReactNative.Style.t=?, 68 | unit, 69 | ) => React.element = "MaterialCommunityIcons" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/screens/LoginScreen.res: -------------------------------------------------------------------------------- 1 | open ReactNative 2 | open TableView 3 | 4 | module M = { 5 | type params 6 | type options 7 | } 8 | module Navigation = ReactNavigation.Core.NavigationScreenProp(M) 9 | 10 | @react.component 11 | let make = (~navigation, ~route as _) => { 12 | let {colors} = Theme.context->React.useContext->Option.getExn 13 | let auth = Auth.context->React.useContext 14 | 15 | let (username, setUsername) = React.useState(_ => "") 16 | let (password, setPassword) = React.useState(_ => "") 17 | 18 | 19 |
20 | setUsername(_ => v)} 25 | style={Style.textStyle(~fontSize=16., ~flex=1., ())} 26 | placeholder="Username" 27 | />} 28 | /> 29 | setPassword(_ => v)} 34 | style={Style.textStyle(~fontSize=16., ~flex=1., ())} 35 | placeholder="Password" 36 | secureTextEntry=true 37 | />} 38 | /> 39 |
40 | 41 | 42 |