├── .eslintrc.js ├── .gitignore ├── README.md ├── api └── endpoints.ts ├── app.json ├── app ├── [itemId].tsx ├── _layout.tsx ├── index.tsx └── users │ └── [userId].tsx ├── assets ├── fonts │ └── SpaceMono-Regular.ttf ├── icons │ └── logo.tsx └── images │ ├── adaptive-icon.png │ ├── favicon.png │ ├── icon.png │ └── splash.png ├── babel.config.js ├── components ├── Avatar.tsx ├── Select.tsx ├── Spinner.tsx ├── comments │ ├── comment.tsx │ └── comments.tsx └── posts │ ├── Post.tsx │ ├── Posts.tsx │ └── user-activities │ └── UserActivities.tsx ├── constants ├── Colors.ts ├── item.ts ├── pagination.ts ├── stories.ts └── user.ts ├── expo-env.d.ts ├── lib └── text.ts ├── package-lock.json ├── package.json ├── scripts └── reset-project.js ├── shared └── types.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // https://docs.expo.dev/guides/using-eslint/ 2 | import pluginQuery from "@tanstack/eslint-plugin-query"; 3 | 4 | module.exports = [{ extends: "expo" }, pluginQuery.configs["flat/recommended"]]; 5 | -------------------------------------------------------------------------------- /.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 | # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb 17 | # The following patterns were generated by expo-cli 18 | 19 | expo-env.d.ts 20 | # @end expo-cli 21 | 22 | ios/ 23 | android/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hacker Native 🆈 ⚛️ 2 | 3 | A Hacker News client for reading HN content! 4 | 5 | 6 | 7 | Found some [Design Inspiration]( https://dribbble.com/shots/21381309-Mobile-News-Site-Redesign-Hacker-News#) on Dribbble! 8 | 9 | This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). 10 | 11 | ## Features 12 | 13 | - [X] Show List of Posts 14 | - [X] Show link, comment and upvote count 15 | - [X] Restore scroll position when navigating to details and get back to list screen 16 | - [X] Filter home list to show between: 17 | - [X] topstories.json 18 | - [X] besttories.json 19 | - [X] asktories.json 20 | - [X] showtories.json 21 | - [X] Show post details 22 | - [X] Post Details 23 | - [X] Post Comments 24 | - [X] Show comment details 25 | - [X] Comment Details 26 | - [X] User account 27 | - [X] Show User details 28 | - [X] Navigate user Posts, Polls or Comments 29 | - [ ] Enhance text rendering (Code blocks (pre>code), blockquote, list, inline code (code)) 30 | -------------------------------------------------------------------------------- /api/endpoints.ts: -------------------------------------------------------------------------------- 1 | import type { Item, User } from "@/shared/types"; 2 | 3 | interface TypedResponse extends Response { 4 | json(): Promise; 5 | } 6 | 7 | export type GetStoriesEndpoint = () => Promise>; 8 | 9 | export const getTopStories = () => { 10 | return fetch("https://hacker-news.firebaseio.com/v0/topstories.json", { 11 | method: "GET", 12 | }) as Promise>; 13 | }; 14 | 15 | export const getBestStories = () => { 16 | return fetch("https://hacker-news.firebaseio.com/v0/beststories.json", { 17 | method: "GET", 18 | }) as Promise>; 19 | }; 20 | 21 | export const getAskStories = () => { 22 | return fetch("https://hacker-news.firebaseio.com/v0/askstories.json", { 23 | method: "GET", 24 | }) as Promise>; 25 | }; 26 | 27 | export const getShowStories = () => { 28 | return fetch("https://hacker-news.firebaseio.com/v0/showstories.json", { 29 | method: "GET", 30 | }) as Promise>; 31 | }; 32 | 33 | export const getItemDetails = (id: number | string) => { 34 | return fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`, { 35 | method: "GET", 36 | }) as Promise>; 37 | }; 38 | 39 | export const getUserDetails = (id: number | string) => { 40 | return fetch(`https://hacker-news.firebaseio.com/v0/user/${id}.json`, { 41 | method: "GET", 42 | }) as Promise>; 43 | }; 44 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "Hacker Native", 4 | "slug": "hacker-native", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "myapp", 9 | "userInterfaceStyle": "automatic", 10 | "splash": { 11 | "image": "./assets/images/splash.png", 12 | "resizeMode": "contain", 13 | "backgroundColor": "#ffffff" 14 | }, 15 | "ios": { 16 | "supportsTablet": true, 17 | "bundleIdentifier": "dev.akamfoad.hackernative" 18 | }, 19 | "android": { 20 | "adaptiveIcon": { 21 | "foregroundImage": "./assets/images/adaptive-icon.png", 22 | "backgroundColor": "#ffffff" 23 | } 24 | }, 25 | "web": { 26 | "bundler": "metro", 27 | "output": "static", 28 | "favicon": "./assets/images/favicon.png" 29 | }, 30 | "plugins": ["expo-router"], 31 | "experiments": { 32 | "typedRoutes": true 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/[itemId].tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Text, 3 | View, 4 | Linking, 5 | Platform, 6 | Pressable, 7 | StyleSheet, 8 | useWindowDimensions, 9 | } from "react-native"; 10 | import * as Haptics from "expo-haptics"; 11 | import { useQuery } from "@tanstack/react-query"; 12 | import RenderHTML from "react-native-render-html"; 13 | import { formatDistanceToNowStrict } from "date-fns"; 14 | import { router, Stack, useLocalSearchParams } from "expo-router"; 15 | import { ArrowRightIcon, Link2, MessageSquareText } from "lucide-react-native"; 16 | 17 | import { parseTitle } from "@/lib/text"; 18 | import { Colors } from "@/constants/Colors"; 19 | import { Comments } from "@/components/comments/comments"; 20 | import { getItemDetailsQueryKey, getItemQueryFn } from "@/constants/item"; 21 | 22 | export default function ItemDetails() { 23 | const { itemId } = useLocalSearchParams(); 24 | const { width: windowWidth } = useWindowDimensions(); 25 | 26 | if (typeof itemId !== "string") { 27 | return router.back(); 28 | } 29 | 30 | const { data: item } = useQuery({ 31 | queryKey: getItemDetailsQueryKey(itemId), 32 | queryFn: getItemQueryFn, 33 | }); 34 | 35 | const { data: parentItem } = useQuery({ 36 | queryKey: getItemDetailsQueryKey(item?.parent || 0), 37 | queryFn: getItemQueryFn, 38 | enabled: !!item?.parent && item.type === "comment", 39 | }); 40 | 41 | return ( 42 | 43 | 51 | {item && ( 52 | 53 | {typeof item.title === "string" && ( 54 | 55 | 56 | {item.title} 57 | 58 | 59 | )} 60 | 66 | 67 | 75 | router.push(`/users/${item.by}`)}> 76 | 87 | {item.by} 88 | 89 | 90 | {item.time && ( 91 | 92 | posted{" "} 93 | {formatDistanceToNowStrict(new Date(item.time * 1000), { 94 | addSuffix: true, 95 | })} 96 | 97 | )} 98 | 99 | {typeof item.text === "string" && ( 100 | 110 | )} 111 | 112 | 120 | { 123 | await Haptics.notificationAsync( 124 | Haptics.NotificationFeedbackType.Success 125 | ); 126 | }} 127 | > 128 | 137 | {" "} 138 | {item.score || 0} 139 | 140 | 141 | { 144 | await Haptics.notificationAsync( 145 | Haptics.NotificationFeedbackType.Warning 146 | ); 147 | }} 148 | > 149 | 150 | 159 | {item.kids?.length || 0} 160 | 161 | 162 | {item.url && ( 163 | { 166 | Linking.openURL(item.url); 167 | }} 168 | > 169 | 170 | 180 | {new URL(item.url).host} 181 | 182 | 183 | )} 184 | 185 | 186 | {parentItem ? ( 187 | router.push(`../${parentItem.id}`)} 198 | > 199 | 206 | 207 | Commented on: 208 | 209 | 210 | 211 | 221 | {parseTitle((parentItem.title || parentItem.text).slice(0, 72))} 222 | {(parentItem.title || parentItem.text).length > 72 ? "..." : ""} 223 | 224 | 225 | ) : null} 226 | 227 | )} 228 | 229 | ); 230 | } 231 | 232 | const styles = StyleSheet.create({ 233 | page: { 234 | flex: 1, 235 | }, 236 | backButton: { 237 | paddingHorizontal: 22, 238 | marginBottom: 22, 239 | flexDirection: "row", 240 | alignItems: "center", 241 | gap: 4, 242 | }, 243 | backButtonText: { 244 | fontSize: 18, 245 | color: Colors.accent, 246 | fontWeight: 600, 247 | }, 248 | baseButton: { 249 | flexDirection: "row", 250 | alignItems: "center", 251 | gap: 4, 252 | borderRadius: 20, 253 | paddingVertical: 6, 254 | paddingHorizontal: 16, 255 | }, 256 | link: { paddingHorizontal: 0, paddingVertical: 0 }, 257 | button: { 258 | backgroundColor: "#e2e8f0", 259 | }, 260 | }); 261 | -------------------------------------------------------------------------------- /app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { View } from "react-native"; 2 | import { Stack } from "expo-router"; 3 | import { 4 | SafeAreaProvider, 5 | useSafeAreaInsets, 6 | } from "react-native-safe-area-context"; 7 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 8 | 9 | import { Colors } from "@/constants/Colors"; 10 | 11 | const queryClient = new QueryClient(); 12 | 13 | export default function Layout() { 14 | const safeArea = useSafeAreaInsets(); 15 | 16 | return ( 17 | <> 18 | 19 | 20 | ( 23 | 29 | ), 30 | headerBackTitleVisible: false, 31 | headerTintColor: "#f1f1f1", 32 | headerBackButtonMenuEnabled: true, 33 | headerStyle: { 34 | backgroundColor: Colors.accent, 35 | }, 36 | }} 37 | /> 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/index.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from "expo-router"; 2 | import { useMemo, useState } from "react"; 3 | import { Platform, StyleSheet, Text, View } from "react-native"; 4 | 5 | import { Posts } from "@/components/posts/Posts"; 6 | import { Option, StoriesSelect } from "@/components/Select"; 7 | 8 | import { 9 | MAP_STORY_TYPE_TO_ICON, 10 | StoryType, 11 | storyTypes, 12 | } from "@/constants/stories"; 13 | import { Colors } from "@/constants/Colors"; 14 | 15 | export default function HomeScreen() { 16 | const [storyType, setStoryType] = useState("topstories"); 17 | 18 | const storyOptions: Option[] = useMemo(() => { 19 | return storyTypes.map(({ label, type }) => ({ 20 | id: type, 21 | label, 22 | icon: MAP_STORY_TYPE_TO_ICON[type], 23 | })); 24 | }, []); 25 | 26 | return ( 27 | <> 28 | ( 32 | 33 | {"{"} 34 | HACKER_NATIVE 35 | {"}"} 36 | 37 | ), 38 | }} 39 | /> 40 | 41 | 42 | 47 | 48 | ); 49 | } 50 | const styles = StyleSheet.create({ 51 | nameContainer: { 52 | backgroundColor: Colors.accent, 53 | flexDirection: "row", 54 | alignItems: "center", 55 | }, 56 | dimmedText: { 57 | color: "#fcc29d", 58 | fontSize: 22, 59 | lineHeight: 22, 60 | }, 61 | name: { 62 | color: "#fcfcfb", 63 | marginHorizontal: 2, 64 | fontSize: 18, 65 | fontFamily: Platform.select({ 66 | ios: "Menlo", 67 | android: "monospace", 68 | default: "monospace", 69 | }), 70 | }, 71 | }); 72 | -------------------------------------------------------------------------------- /app/users/[userId].tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Text, 3 | View, 4 | Platform, 5 | StyleSheet, 6 | useWindowDimensions, 7 | } from "react-native"; 8 | import { useQuery } from "@tanstack/react-query"; 9 | import RenderHTML from "react-native-render-html"; 10 | import { router, Stack, useLocalSearchParams } from "expo-router"; 11 | 12 | import { Activities } from "@/components/posts/user-activities/UserActivities"; 13 | 14 | import { getUserDetailsQueryKey, getUserQueryFn } from "@/constants/user"; 15 | import { Avatar } from "@/components/Avatar"; 16 | 17 | export default function UserDetails() { 18 | const { userId } = useLocalSearchParams(); 19 | const { width: windowWidth } = useWindowDimensions(); 20 | 21 | if (typeof userId !== "string") { 22 | return router.back(); 23 | } 24 | 25 | const { data: user } = useQuery({ 26 | queryKey: getUserDetailsQueryKey(userId), 27 | queryFn: getUserQueryFn, 28 | }); 29 | 30 | return ( 31 | 32 | 33 | {user && ( 34 | 35 | 36 | 37 | {userId} 38 | 🔥 {user.karma || 0} 39 | {typeof user.about === "string" && ( 40 | 45 | )} 46 | 47 | Recent activities: 48 | 49 | )} 50 | 51 | ); 52 | } 53 | 54 | const styles = StyleSheet.create({ 55 | page: { flex: 1 }, 56 | profile: { alignItems: "center", marginTop: 22 }, 57 | username: { 58 | marginTop: 22, 59 | color: "#030712", 60 | fontSize: 20, 61 | fontWeight: "600", 62 | fontFamily: Platform.select({ 63 | ios: "Menlo", 64 | android: "monospace", 65 | default: "monospace", 66 | }), 67 | }, 68 | karma: { 69 | marginVertical: 10, 70 | color: "#1f2937", 71 | fontSize: 16, 72 | fontWeight: "500", 73 | fontFamily: Platform.select({ 74 | ios: "Menlo", 75 | android: "monospace", 76 | default: "monospace", 77 | }), 78 | }, 79 | about: { 80 | fontSize: 14, 81 | lineHeight: 22, 82 | fontWeight: 400, 83 | marginVertical: 16, 84 | color: "#1f2937", 85 | fontFamily: Platform.select({ 86 | ios: "Menlo", 87 | android: "monospace", 88 | default: "monospace", 89 | }), 90 | }, 91 | recentActivitiesLabel: { 92 | fontFamily: Platform.select({ 93 | ios: "Menlo", 94 | android: "monospace", 95 | default: "monospace", 96 | }), 97 | marginTop: 14, 98 | marginBottom: 22, 99 | fontSize: 18, 100 | color: "#0f172a", 101 | fontWeight: "700", 102 | }, 103 | }); 104 | -------------------------------------------------------------------------------- /assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akamfoad/hacker-native/21f48bf3a13bbed94f72a1bfe6a4ebe330b7d74c/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /assets/icons/logo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Svg, { SvgProps, Rect, Path } from "react-native-svg"; 3 | 4 | export const Logo = (props: SvgProps) => ( 5 | 6 | 7 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akamfoad/hacker-native/21f48bf3a13bbed94f72a1bfe6a4ebe330b7d74c/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akamfoad/hacker-native/21f48bf3a13bbed94f72a1bfe6a4ebe330b7d74c/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akamfoad/hacker-native/21f48bf3a13bbed94f72a1bfe6a4ebe330b7d74c/assets/images/icon.png -------------------------------------------------------------------------------- /assets/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akamfoad/hacker-native/21f48bf3a13bbed94f72a1bfe6a4ebe330b7d74c/assets/images/splash.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ["babel-preset-expo"], 5 | plugins: [ 6 | "react-native-reanimated/plugin", // this should be last plugin 7 | ], 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Platform, StyleSheet, Text, View } from "react-native"; 3 | 4 | type Props = { 5 | text: string; 6 | }; 7 | 8 | export const Avatar = ({ text }: Props) => { 9 | return ( 10 | 11 | {text.charAt(0).toUpperCase()} 12 | 13 | ); 14 | }; 15 | 16 | const styles = StyleSheet.create({ 17 | avatar: { 18 | width: 42, 19 | height: 42, 20 | borderRadius: 42, 21 | justifyContent: "center", 22 | alignItems: "center", 23 | backgroundColor: "#d4d4d8", 24 | }, 25 | text: { 26 | color: "#030712", 27 | textTransform: "uppercase", 28 | fontSize: 22, 29 | fontWeight: "600", 30 | fontFamily: Platform.select({ 31 | ios: "Menlo", 32 | android: "monospace", 33 | default: "monospace", 34 | }), 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /components/Select.tsx: -------------------------------------------------------------------------------- 1 | import Animated, { 2 | SlideInDown, 3 | SlideOutDown, 4 | useAnimatedProps, 5 | useSharedValue, 6 | withDelay, 7 | withTiming, 8 | } from "react-native-reanimated"; 9 | import { BlurView } from "expo-blur"; 10 | import { useEffect, useMemo, useState } from "react"; 11 | import { Pressable, StyleSheet, Text } from "react-native"; 12 | import { ListFilter, LucideIcon } from "lucide-react-native"; 13 | 14 | import { Colors } from "@/constants/Colors"; 15 | import { StoryType } from "@/constants/stories"; 16 | 17 | export type Option = { 18 | id: StoryType; 19 | label: string; 20 | icon: LucideIcon; 21 | }; 22 | 23 | type Props = { 24 | options: Option[]; 25 | value: Option["id"]; 26 | onChange: (newItem: Option["id"]) => void; 27 | defaultOpen?: boolean; 28 | }; 29 | 30 | const AnimatedBlurView = Animated.createAnimatedComponent(BlurView); 31 | 32 | const listEnterAnimation = SlideInDown.delay(50); 33 | 34 | export const StoriesSelect = ({ 35 | onChange, 36 | value, 37 | options, 38 | defaultOpen = false, 39 | }: Props) => { 40 | const [isOpen, setIsOpen] = useState(defaultOpen); 41 | 42 | const selectedOption = useMemo( 43 | () => options.find((option) => option.id === value), 44 | [options, value] 45 | ); 46 | 47 | const intensity = useSharedValue(0); 48 | 49 | const animatedProps = useAnimatedProps(() => ({ 50 | intensity: intensity.value, 51 | })); 52 | 53 | useEffect(() => { 54 | intensity.value = withDelay( 55 | isOpen ? 0 : 50, 56 | withTiming(isOpen ? 10 : 0, { duration: 200 }) 57 | ); 58 | }, [isOpen]); 59 | 60 | return ( 61 | <> 62 | setIsOpen(false)} 74 | /> 75 | {isOpen && ( 76 | 81 | {options.map((item) => { 82 | const isSelected = value === item.id; 83 | 84 | return ( 85 | { 92 | onChange(item.id); 93 | setIsOpen(false); 94 | }} 95 | > 96 | 100 | {item.label} 101 | 102 | ); 103 | })} 104 | 105 | )} 106 | setIsOpen((prev) => !prev)} 109 | > 110 | 111 | 112 | {selectedOption ? selectedOption.label : "Select"} 113 | 114 | 115 | 116 | ); 117 | }; 118 | 119 | const styles = StyleSheet.create({ 120 | root: { 121 | backgroundColor: "#e4e4e7", 122 | padding: 8, 123 | borderRadius: 10, 124 | shadowColor: "#451a03", 125 | shadowOffset: { height: 1, width: 1 }, 126 | shadowOpacity: 0.1, 127 | shadowRadius: 5, 128 | position: "absolute", 129 | bottom: "10%", 130 | right: "2%", 131 | minWidth: "50%", 132 | width: "100%", 133 | maxWidth: 240, 134 | gap: 8, 135 | borderWidth: 1, 136 | borderColor: "#d4d4d8", 137 | }, 138 | trigger: { 139 | position: "absolute", 140 | bottom: "3%", 141 | right: "2%", 142 | backgroundColor: Colors.accent, 143 | paddingHorizontal: 20, 144 | paddingVertical: 8, 145 | borderRadius: 40, 146 | flexDirection: "row", 147 | gap: 10, 148 | alignItems: "center", 149 | justifyContent: "center", 150 | minHeight: 48, 151 | }, 152 | triggerLabel: { 153 | color: "#f1f1f1", 154 | fontSize: 18, 155 | lineHeight: 24, 156 | fontWeight: "600", 157 | }, 158 | option: { 159 | gap: 6, 160 | flexDirection: "row", 161 | alignItems: "center", 162 | paddingVertical: 10, 163 | paddingHorizontal: 6, 164 | borderWidth: 1, 165 | borderColor: "transparent", 166 | borderRadius: 10, 167 | }, 168 | optionSelected: { 169 | borderColor: Colors.accent, 170 | }, 171 | optionLabel: { 172 | fontSize: 18, 173 | fontWeight: "500", 174 | }, 175 | }); 176 | -------------------------------------------------------------------------------- /components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { Loader } from "lucide-react-native"; 3 | import { Animated, Easing } from "react-native"; 4 | 5 | type Props = { 6 | variant: "dark" | "light"; 7 | }; 8 | export const Spinner = ({ variant }: Props) => { 9 | const rotateAnimation = new Animated.Value(0); 10 | 11 | useEffect(() => { 12 | Animated.loop( 13 | Animated.timing(rotateAnimation, { 14 | toValue: 1, 15 | duration: 2000, 16 | easing: Easing.linear, 17 | useNativeDriver: true, 18 | }) 19 | ).start(); 20 | }, []); 21 | 22 | const rotate = rotateAnimation.interpolate({ 23 | inputRange: [0, 1], 24 | outputRange: ["0deg", "360deg"], 25 | }); 26 | 27 | return ( 28 | 33 | 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /components/comments/comment.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Text, 3 | View, 4 | Platform, 5 | Pressable, 6 | StyleSheet, 7 | useWindowDimensions, 8 | } from "react-native"; 9 | import * as Haptics from "expo-haptics"; 10 | import RenderHTML from "react-native-render-html"; 11 | import { router, usePathname } from "expo-router"; 12 | import { formatDistanceToNowStrict } from "date-fns"; 13 | import { useQueryClient } from "@tanstack/react-query"; 14 | import { MessageSquareText } from "lucide-react-native"; 15 | 16 | import type { Item } from "@/shared/types"; 17 | import { Colors } from "@/constants/Colors"; 18 | import { getItemDetailsQueryKey, getItemQueryFn } from "@/constants/item"; 19 | 20 | export const Comment = (item: Item) => { 21 | const QC = useQueryClient(); 22 | const pathname = usePathname(); 23 | const { width: windowWidth } = useWindowDimensions(); 24 | 25 | return ( 26 | 33 | 34 | router.push(`/users/${item.by}`)} 37 | > 38 | 49 | {item.by} 50 | 51 | 52 | {item.time && ( 53 | 54 | {formatDistanceToNowStrict(new Date(item.time * 1000), { 55 | addSuffix: true, 56 | })} 57 | 58 | )} 59 | 60 | {typeof item.text === "string" && ( 61 | 66 | 75 | 76 | )} 77 | 78 | { 81 | await Haptics.notificationAsync( 82 | Haptics.NotificationFeedbackType.Success 83 | ); 84 | }} 85 | > 86 | 95 | {" "} 96 | {item.score || 0} 97 | 98 | 99 | { 102 | await QC.prefetchQuery({ 103 | queryKey: getItemDetailsQueryKey(item.id), 104 | queryFn: getItemQueryFn, 105 | }); 106 | router.push({ pathname: `../${item.id.toString()}` }); 107 | }} 108 | > 109 | 110 | 119 | {item.kids?.length || 0} 120 | 121 | 122 | 123 | 124 | ); 125 | }; 126 | 127 | const styles = StyleSheet.create({ 128 | baseButton: { 129 | flexDirection: "row", 130 | alignItems: "center", 131 | gap: 4, 132 | borderRadius: 20, 133 | paddingVertical: 6, 134 | paddingHorizontal: 16, 135 | }, 136 | link: { paddingHorizontal: 0, paddingVertical: 0 }, 137 | button: { 138 | backgroundColor: "#e2e8f0", 139 | maxHeight: 32, 140 | minHeight: 32, 141 | }, 142 | }); 143 | -------------------------------------------------------------------------------- /components/comments/comments.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useMemo } from "react"; 2 | import { useInfiniteQuery } from "@tanstack/react-query"; 3 | import { FlatList, ListRenderItem, View } from "react-native"; 4 | import { useSafeAreaInsets } from "react-native-safe-area-context"; 5 | 6 | import { Spinner } from "@/components/Spinner"; 7 | import { Comment } from "@/components/comments/comment"; 8 | 9 | import type { Item } from "@/shared/types"; 10 | import { getItemDetails } from "@/api/endpoints"; 11 | import { ITEMS_PER_PAGE } from "@/constants/pagination"; 12 | 13 | type Props = Pick & { 14 | children: ReactNode; 15 | }; 16 | 17 | export const Comments = ({ id, kids, children }: Props) => { 18 | const { bottom } = useSafeAreaInsets(); 19 | const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } = 20 | useInfiniteQuery({ 21 | queryKey: [id, "comments"], 22 | queryFn: async ({ pageParam = 0 }) => { 23 | if (!kids) return []; 24 | 25 | const pageIds = kids.slice(pageParam, pageParam + ITEMS_PER_PAGE); 26 | const detailsResponses = await Promise.all( 27 | pageIds.map((id) => getItemDetails(id)) 28 | ); 29 | const posts = await Promise.all( 30 | detailsResponses.map((res) => res.json()) 31 | ); 32 | 33 | return posts; 34 | }, 35 | getNextPageParam: (lastPage, allPages) => { 36 | if (!kids) return undefined; 37 | 38 | const nextPage = allPages.length * ITEMS_PER_PAGE; 39 | return nextPage < kids.length ? nextPage : undefined; 40 | }, 41 | enabled: !!kids, 42 | initialPageParam: 0, 43 | }); 44 | 45 | const comments = useMemo(() => { 46 | return data?.pages 47 | .flat() 48 | .filter(({ dead, deleted }) => dead !== true && deleted !== true); 49 | }, [data]); 50 | 51 | return ( 52 | children} 55 | style={{ paddingHorizontal: 22 }} 56 | keyExtractor={(item) => item.id.toString()} 57 | data={comments} 58 | onEndReachedThreshold={0.5} 59 | onEndReached={() => { 60 | if (hasNextPage) fetchNextPage(); 61 | }} 62 | contentContainerStyle={{ flexGrow: 1 }} 63 | renderItem={renderItem} 64 | ListFooterComponent={() => { 65 | if (!isLoading) return ; 66 | 67 | return ( 68 | 75 | 76 | 77 | ); 78 | }} 79 | ItemSeparatorComponent={ItemSeparatorComponent} 80 | /> 81 | ); 82 | }; 83 | 84 | const renderItem: ListRenderItem = ({ item }) => { 85 | return ; 86 | }; 87 | 88 | const ItemSeparatorComponent = () => ( 89 | 90 | ); 91 | -------------------------------------------------------------------------------- /components/posts/Post.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Text, 3 | View, 4 | Linking, 5 | Platform, 6 | Pressable, 7 | StyleSheet, 8 | } from "react-native"; 9 | import { useMemo } from "react"; 10 | import { router } from "expo-router"; 11 | import * as Haptics from "expo-haptics"; 12 | import { useQueryClient } from "@tanstack/react-query"; 13 | import { Link2, MessageSquareText } from "lucide-react-native"; 14 | 15 | import type { Item } from "@/shared/types"; 16 | import { getItemDetailsQueryKey, getItemQueryFn } from "@/constants/item"; 17 | 18 | export const Post = ({ id, title, url, score, text, kids }: Item) => { 19 | const QC = useQueryClient(); 20 | 21 | const isExternal = useMemo(() => { 22 | return text === undefined; 23 | }, [text]); 24 | 25 | const navigateToDetails = async () => { 26 | await QC.prefetchQuery({ 27 | queryKey: getItemDetailsQueryKey(id), 28 | queryFn: getItemQueryFn, 29 | }); 30 | router.push({ pathname: `../${id.toString()}` }); 31 | }; 32 | 33 | return ( 34 | 35 | { 37 | if (isExternal) Linking.openURL(url); 38 | else await navigateToDetails(); 39 | }} 40 | > 41 | 42 | {title} 43 | 44 | 45 | 46 | { 49 | await Haptics.notificationAsync( 50 | Haptics.NotificationFeedbackType.Success 51 | ); 52 | }} 53 | > 54 | 63 | {score} 64 | 65 | 66 | { 69 | await navigateToDetails(); 70 | }} 71 | > 72 | 73 | 82 | {kids?.length || 0} 83 | 84 | 85 | {url && ( 86 | { 89 | Linking.openURL(url); 90 | }} 91 | > 92 | 93 | 103 | {new URL(url).host} 104 | 105 | 106 | )} 107 | 108 | 109 | ); 110 | }; 111 | 112 | const styles = StyleSheet.create({ 113 | baseButton: { 114 | flexDirection: "row", 115 | alignItems: "center", 116 | gap: 4, 117 | borderRadius: 20, 118 | paddingVertical: 6, 119 | paddingHorizontal: 16, 120 | }, 121 | link: { paddingHorizontal: 0, paddingVertical: 0 }, 122 | button: { 123 | backgroundColor: "#e2e8f0", 124 | maxHeight: 32, 125 | minHeight: 32, 126 | }, 127 | }); 128 | -------------------------------------------------------------------------------- /components/posts/Posts.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { FlatList, ListRenderItem, View } from "react-native"; 3 | import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; 4 | 5 | import { Post } from "@/components/posts/Post"; 6 | import { Spinner } from "@/components/Spinner"; 7 | 8 | import type { Item } from "@/shared/types"; 9 | import { getItemDetails } from "@/api/endpoints"; 10 | import { ITEMS_PER_PAGE } from "@/constants/pagination"; 11 | import { 12 | type StoryType, 13 | MAP_STORY_TYPE_TO_STORY_ENDPOINTS, 14 | } from "@/constants/stories"; 15 | 16 | const renderItem: ListRenderItem = ({ item }) => { 17 | return ( 18 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | const ItemSeparatorComponent = () => ( 25 | 26 | ); 27 | 28 | export const Posts = ({ storyType }: { storyType: StoryType }) => { 29 | const storyListQuery = useQuery({ 30 | queryKey: ["storyIds", storyType], 31 | queryFn: async () => { 32 | const getItemIds = MAP_STORY_TYPE_TO_STORY_ENDPOINTS[storyType]; 33 | const res = await getItemIds(); 34 | const topStories = await res.json(); 35 | 36 | return topStories; 37 | }, 38 | }); 39 | 40 | const { data, hasNextPage, fetchNextPage, isLoading } = useInfiniteQuery({ 41 | queryKey: ["storyDetails", storyType], 42 | queryFn: async ({ pageParam = 0 }) => { 43 | if (!storyListQuery.data) return []; 44 | 45 | const pageIds = storyListQuery.data.slice( 46 | pageParam, 47 | pageParam + ITEMS_PER_PAGE 48 | ); 49 | const detailsResponses = await Promise.all( 50 | pageIds.map((id) => getItemDetails(id)) 51 | ); 52 | const posts = await Promise.all( 53 | detailsResponses.map((res) => res.json()) 54 | ); 55 | 56 | return posts; 57 | }, 58 | getNextPageParam: (lastPage, allPages) => { 59 | if (!storyListQuery.data) return undefined; 60 | 61 | const nextPage = allPages.length * ITEMS_PER_PAGE; 62 | return nextPage < storyListQuery.data.length ? nextPage : undefined; 63 | }, 64 | enabled: !!storyListQuery.data, 65 | initialPageParam: 0, 66 | }); 67 | 68 | const posts = useMemo(() => { 69 | return data?.pages 70 | .flat() 71 | .filter(({ dead, deleted }) => dead !== true && deleted !== true); 72 | }, [data]); 73 | 74 | return ( 75 | item.id.toString()} 78 | data={posts} 79 | onEndReachedThreshold={0.5} 80 | onEndReached={() => { 81 | if (hasNextPage) fetchNextPage(); 82 | }} 83 | contentContainerStyle={{ flexGrow: 1 }} 84 | renderItem={renderItem} 85 | ListFooterComponent={() => { 86 | if (!isLoading) return null; 87 | 88 | return ( 89 | 96 | 97 | 98 | ); 99 | }} 100 | ItemSeparatorComponent={ItemSeparatorComponent} 101 | /> 102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /components/posts/user-activities/UserActivities.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useMemo } from "react"; 2 | import { useInfiniteQuery } from "@tanstack/react-query"; 3 | import { FlatList, ListRenderItem, View } from "react-native"; 4 | import { useSafeAreaInsets } from "react-native-safe-area-context"; 5 | 6 | import { Post } from "@/components/posts/Post"; 7 | import { Spinner } from "@/components/Spinner"; 8 | import { Comment } from "@/components/comments/comment"; 9 | 10 | import { getItemDetails } from "@/api/endpoints"; 11 | import type { Item, User } from "@/shared/types"; 12 | import { ITEMS_PER_PAGE } from "@/constants/pagination"; 13 | 14 | type Props = Pick & { 15 | children: ReactNode; 16 | }; 17 | 18 | export const Activities = ({ id, submitted, children }: Props) => { 19 | const { bottom } = useSafeAreaInsets(); 20 | const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } = 21 | useInfiniteQuery({ 22 | queryKey: [id, "activities"], 23 | queryFn: async ({ pageParam = 0 }) => { 24 | if (!submitted) return []; 25 | 26 | const pageIds = submitted.slice(pageParam, pageParam + ITEMS_PER_PAGE); 27 | const detailsResponses = await Promise.all( 28 | pageIds.map((id) => getItemDetails(id)) 29 | ); 30 | const activities = await Promise.all( 31 | detailsResponses.map((res) => res.json()) 32 | ); 33 | 34 | return activities; 35 | }, 36 | getNextPageParam: (lastPage, allPages) => { 37 | if (!submitted) return undefined; 38 | 39 | const nextPage = allPages.length * ITEMS_PER_PAGE; 40 | return nextPage < submitted.length ? nextPage : undefined; 41 | }, 42 | enabled: !!submitted, 43 | initialPageParam: 0, 44 | }); 45 | 46 | const activities = useMemo(() => { 47 | return data?.pages 48 | .flat() 49 | .filter(({ dead, deleted }) => dead !== true && deleted !== true); 50 | }, [data]); 51 | 52 | return ( 53 | children} 56 | style={{ paddingHorizontal: 22 }} 57 | keyExtractor={(item) => item.id.toString()} 58 | data={activities} 59 | onEndReachedThreshold={0.5} 60 | onEndReached={() => { 61 | if (hasNextPage) fetchNextPage(); 62 | }} 63 | contentContainerStyle={{ flexGrow: 1 }} 64 | renderItem={renderItem} 65 | ListFooterComponent={() => { 66 | if (!isLoading) return ; 67 | 68 | return ( 69 | 76 | 77 | 78 | ); 79 | }} 80 | ItemSeparatorComponent={ItemSeparatorComponent} 81 | /> 82 | ); 83 | }; 84 | 85 | const renderItem: ListRenderItem = ({ item }) => { 86 | return item.type === "comment" ? : ; 87 | }; 88 | 89 | const ItemSeparatorComponent = () => ( 90 | 91 | ); 92 | -------------------------------------------------------------------------------- /constants/Colors.ts: -------------------------------------------------------------------------------- 1 | export const Colors = { 2 | accent: "#ff6600", 3 | }; 4 | -------------------------------------------------------------------------------- /constants/item.ts: -------------------------------------------------------------------------------- 1 | import { getItemDetails } from "@/api/endpoints"; 2 | import { Item } from "@/shared/types"; 3 | import { QueryFunction } from "@tanstack/react-query"; 4 | 5 | export const getItemDetailsQueryKey = (itemId: number | string) => [ 6 | "storyDetails", 7 | itemId, 8 | ]; 9 | 10 | export const getItemQueryFn: QueryFunction = async ({ queryKey }) => { 11 | const itemId = queryKey.at(-1); 12 | if (typeof itemId !== "string" && typeof itemId !== "number") { 13 | throw new Error( 14 | `Expected last queryKey item to be an id, instead got ${itemId}` 15 | ); 16 | } 17 | const res = await getItemDetails(itemId); 18 | const details = await res.json(); 19 | 20 | return details; 21 | }; 22 | -------------------------------------------------------------------------------- /constants/pagination.ts: -------------------------------------------------------------------------------- 1 | export const ITEMS_PER_PAGE = 10 ; 2 | -------------------------------------------------------------------------------- /constants/stories.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getAskStories, 3 | getBestStories, 4 | getShowStories, 5 | GetStoriesEndpoint, 6 | getTopStories, 7 | } from "@/api/endpoints"; 8 | import { 9 | AwardIcon, 10 | CrownIcon, 11 | LucideIcon, 12 | MessageCircleQuestionIcon, 13 | PresentationIcon 14 | } from "lucide-react-native"; 15 | 16 | export type StoryType = 17 | | "topstories" 18 | | "beststories" 19 | | "askstories" 20 | | "showstories" 21 | 22 | type StoryTypeOption = { 23 | label: string; 24 | type: StoryType; 25 | }; 26 | 27 | export const storyTypes: StoryTypeOption[] = [ 28 | { label: "Top Stories", type: "topstories" }, 29 | { label: "Best Stories", type: "beststories" }, 30 | { label: "Ask Stories", type: "askstories" }, 31 | { label: "Show Stories", type: "showstories" }, 32 | ]; 33 | 34 | export const MAP_STORY_TYPE_TO_ICON: Record = { 35 | topstories: AwardIcon, 36 | beststories: CrownIcon, 37 | askstories: MessageCircleQuestionIcon, 38 | showstories: PresentationIcon, 39 | }; 40 | 41 | export const MAP_STORY_TYPE_TO_STORY_ENDPOINTS: Record< 42 | StoryType, 43 | GetStoriesEndpoint 44 | > = { 45 | topstories: getTopStories, 46 | beststories: getBestStories, 47 | askstories: getAskStories, 48 | showstories: getShowStories, 49 | }; 50 | -------------------------------------------------------------------------------- /constants/user.ts: -------------------------------------------------------------------------------- 1 | import type { QueryFunction } from "@tanstack/react-query"; 2 | 3 | import type { User } from "@/shared/types"; 4 | import { getUserDetails } from "@/api/endpoints"; 5 | 6 | export const getUserDetailsQueryKey = (userId: number | string) => [ 7 | "userDetails", 8 | userId, 9 | ]; 10 | 11 | export const getUserQueryFn: QueryFunction = async ({ queryKey }) => { 12 | const userId = queryKey.at(-1); 13 | if (typeof userId !== "string" && typeof userId !== "number") { 14 | throw new Error( 15 | `Expected last queryKey item to be an id, instead got ${userId}` 16 | ); 17 | } 18 | const res = await getUserDetails(userId); 19 | const details = await res.json(); 20 | 21 | return details; 22 | }; 23 | -------------------------------------------------------------------------------- /expo-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // NOTE: This file should not be edited and should be in your git ignore -------------------------------------------------------------------------------- /lib/text.ts: -------------------------------------------------------------------------------- 1 | import { decode } from "html-entities"; 2 | 3 | const stripHTML = (html: string) => { 4 | return html.replace(/<\/?[^>]+(>|$)/g, ""); 5 | }; 6 | 7 | export const parseTitle = (html: string) => { 8 | return decode(stripHTML(html)).trim(); 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hacker-native", 3 | "main": "expo-router/entry", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "start": "expo start", 7 | "reset-project": "node ./scripts/reset-project.js", 8 | "android": "expo run:android", 9 | "android:device": "expo run:android --device", 10 | "ios": "expo run:ios", 11 | "ios:device": "expo run:ios --device", 12 | "web": "expo start --web", 13 | "test": "jest --watchAll", 14 | "lint": "expo lint" 15 | }, 16 | "jest": { 17 | "preset": "jest-expo" 18 | }, 19 | "dependencies": { 20 | "@expo/vector-icons": "^14.0.2", 21 | "@react-navigation/native": "^6.0.2", 22 | "@tanstack/react-query": "^5.51.23", 23 | "date-fns": "^3.6.0", 24 | "expo": "~51.0.26", 25 | "expo-constants": "~16.0.2", 26 | "expo-dev-client": "~4.0.22", 27 | "expo-font": "~12.0.9", 28 | "expo-haptics": "~13.0.1", 29 | "expo-linking": "~6.3.1", 30 | "expo-router": "~3.5.21", 31 | "expo-splash-screen": "~0.27.5", 32 | "expo-status-bar": "~1.12.1", 33 | "expo-system-ui": "~3.0.7", 34 | "expo-web-browser": "~13.0.3", 35 | "html-entities": "^2.5.2", 36 | "i": "^0.3.7", 37 | "lucide-react-native": "^0.427.0", 38 | "npm": "^10.8.2", 39 | "react": "18.2.0", 40 | "react-dom": "18.2.0", 41 | "react-native": "0.74.5", 42 | "react-native-gesture-handler": "~2.16.1", 43 | "react-native-reanimated": "~3.10.1", 44 | "react-native-render-html": "^6.3.4", 45 | "react-native-safe-area-context": "4.10.5", 46 | "react-native-screens": "3.31.1", 47 | "react-native-svg": "15.2.0", 48 | "react-native-web": "~0.19.10", 49 | "expo-blur": "~13.0.2" 50 | }, 51 | "devDependencies": { 52 | "@babel/core": "^7.20.0", 53 | "@tanstack/eslint-plugin-query": "^5.51.15", 54 | "@types/jest": "^29.5.12", 55 | "@types/react": "~18.2.45", 56 | "@types/react-test-renderer": "^18.0.7", 57 | "eslint": "^8.57.0", 58 | "eslint-config-expo": "^7.1.2", 59 | "eslint-plugin-react-hooks": "^4.6.2", 60 | "jest": "^29.2.1", 61 | "jest-expo": "~51.0.3", 62 | "react-test-renderer": "18.2.0", 63 | "typescript": "~5.3.3" 64 | }, 65 | "private": true 66 | } 67 | -------------------------------------------------------------------------------- /scripts/reset-project.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * This script is used to reset the project to a blank state. 5 | * It moves the /app directory to /app-example and creates a new /app directory with an index.tsx and _layout.tsx file. 6 | * You can remove the `reset-project` script from package.json and safely delete this file after running it. 7 | */ 8 | 9 | const fs = require('fs'); 10 | const path = require('path'); 11 | 12 | const root = process.cwd(); 13 | const oldDirPath = path.join(root, 'app'); 14 | const newDirPath = path.join(root, 'app-example'); 15 | const newAppDirPath = path.join(root, 'app'); 16 | 17 | const indexContent = `import { Text, View } from "react-native"; 18 | 19 | export default function Index() { 20 | return ( 21 | 28 | Edit app/index.tsx to edit this screen. 29 | 30 | ); 31 | } 32 | `; 33 | 34 | const layoutContent = `import { Stack } from "expo-router"; 35 | 36 | export default function RootLayout() { 37 | return ( 38 | 39 | 40 | 41 | ); 42 | } 43 | `; 44 | 45 | fs.rename(oldDirPath, newDirPath, (error) => { 46 | if (error) { 47 | return console.error(`Error renaming directory: ${error}`); 48 | } 49 | console.log('/app moved to /app-example.'); 50 | 51 | fs.mkdir(newAppDirPath, { recursive: true }, (error) => { 52 | if (error) { 53 | return console.error(`Error creating new app directory: ${error}`); 54 | } 55 | console.log('New /app directory created.'); 56 | 57 | const indexPath = path.join(newAppDirPath, 'index.tsx'); 58 | fs.writeFile(indexPath, indexContent, (error) => { 59 | if (error) { 60 | return console.error(`Error creating index.tsx: ${error}`); 61 | } 62 | console.log('app/index.tsx created.'); 63 | 64 | const layoutPath = path.join(newAppDirPath, '_layout.tsx'); 65 | fs.writeFile(layoutPath, layoutContent, (error) => { 66 | if (error) { 67 | return console.error(`Error creating _layout.tsx: ${error}`); 68 | } 69 | console.log('app/_layout.tsx created.'); 70 | }); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /shared/types.ts: -------------------------------------------------------------------------------- 1 | // FIXME change these to subtypes depending on the item type 2 | 3 | export type Item = { 4 | id: number; 5 | deleted: boolean; 6 | type: "job" | "story" | "comment" | "poll" | "pollopt"; 7 | by: string; 8 | time: number; 9 | text: string; 10 | dead: boolean; 11 | parent: number; 12 | poll: number; 13 | kids: number[]; 14 | url: string; 15 | score: number; 16 | title: string; 17 | parts: number[]; 18 | descendants: number; 19 | }; 20 | 21 | export type User = { 22 | id: string; 23 | karma: number; 24 | about: string; 25 | created: number; 26 | submitted: number[]; 27 | }; 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "paths": { 6 | "@/*": [ 7 | "./*" 8 | ] 9 | } 10 | }, 11 | "include": [ 12 | "**/*.ts", 13 | "**/*.tsx", 14 | ".expo/types/**/*.ts", 15 | "expo-env.d.ts" 16 | ] 17 | } 18 | --------------------------------------------------------------------------------