├── .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 |
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 |
--------------------------------------------------------------------------------