├── .env.sample
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── TODO.md
├── api
├── followers.tsx
├── notifications.tsx
├── posts.tsx
├── profiles.tsx
├── storage.tsx
└── users.tsx
├── app.json
├── app
├── (auth)
│ ├── _layout.tsx
│ ├── index.tsx
│ ├── sign-in.tsx
│ └── sign-up.tsx
├── +html.tsx
├── _layout.tsx
├── api
│ ├── followers
│ │ └── index+api.tsx
│ ├── notifications+api.tsx
│ ├── posts+api.tsx
│ ├── presigned-post+api.tsx
│ ├── profile-image+api.tsx
│ ├── profiles+api.tsx
│ ├── profiles
│ │ └── [userId]+api.tsx
│ ├── sign-in+api.tsx
│ ├── sign-up+api.tsx
│ └── users
│ │ └── [userId]
│ │ └── posts+api.tsx
└── dashboard
│ ├── (dashboard,post,notifications,account)
│ ├── _layout.tsx
│ ├── account.tsx
│ ├── index.tsx
│ ├── notifications.tsx
│ ├── post.tsx
│ └── profile
│ │ └── [userId].tsx
│ └── _layout.tsx
├── assets
└── images
│ ├── adaptive-icon.png
│ ├── favicon.png
│ ├── icon.png
│ ├── partial-react-logo.png
│ ├── react-logo.png
│ ├── react-logo@2x.png
│ ├── react-logo@3x.png
│ ├── splash-icon.png
│ └── splash.jpeg
├── babel.config.js
├── bun.lockb
├── components
├── layout
│ ├── modal.module.css
│ ├── modalNavigator.tsx
│ └── modalNavigator.web.tsx
├── runtime
│ ├── local-storage.ts
│ └── local-storage.web.ts
└── ui
│ ├── AppButton.tsx
│ ├── BodyScrollView.tsx
│ ├── ContentUnavailable.tsx
│ ├── FadeIn.tsx
│ ├── Form.tsx
│ ├── Header.tsx
│ ├── IconSymbol.ios.tsx
│ ├── IconSymbol.tsx
│ ├── IconSymbolFallback.tsx
│ ├── Segments.tsx
│ ├── Skeleton.tsx
│ ├── Skeleton.web.tsx
│ ├── Stack.tsx
│ ├── TabBarBackground.ios.tsx
│ ├── TabBarBackground.tsx
│ ├── Tabs.tsx
│ ├── ThemeProvider.tsx
│ ├── TouchableBounce.tsx
│ └── TouchableBounce.web.tsx
├── cors.xml
├── db
├── index.ts
└── schema.ts
├── docker-compose.yml
├── drizzle.config.ts
├── hooks
├── useAuth.tsx
├── useHeaderSearch.ts
├── useMergedRef.ts
└── useTabToTop.ts
├── migrations
├── 0000_soft_electro.sql
├── 0001_remarkable_argent.sql
├── 0002_familiar_rogue.sql
├── 0003_flat_lord_tyger.sql
├── 0004_flowery_young_avengers.sql
└── meta
│ ├── 0000_snapshot.json
│ ├── 0001_snapshot.json
│ ├── 0002_snapshot.json
│ ├── 0003_snapshot.json
│ ├── 0004_snapshot.json
│ └── _journal.json
├── package.json
├── s3.mjs
├── s3
├── error.html
└── index.html
├── tsconfig.json
└── utils
├── auth.ts
├── images.ts
├── storage.ts
└── withAuth.ts
/.env.sample:
--------------------------------------------------------------------------------
1 | DATABASE_URL="postgresql://postgres:example@localhost:5432/postgres"
2 | JWT_SECRET="your-jwt-secret"
3 | USE_LOCAL_S3=true
4 | STORAGE_BUCKET_NAME="get-social"
5 | NODE_ENV=development
6 | EXPO_PUBLIC_STORAGE_BUCKET_NAME="http://localhost:9000/get-social"
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2 |
3 | # dependencies
4 | node_modules/
5 |
6 | # Expo
7 | .expo/
8 | dist/
9 | web-build/
10 | expo-env.d.ts
11 |
12 | # Native
13 | *.orig.*
14 | *.jks
15 | *.p8
16 | *.p12
17 | *.key
18 | *.mobileprovision
19 |
20 | # Metro
21 | .metro-health-check*
22 |
23 | # debug
24 | npm-debug.*
25 | yarn-debug.*
26 | yarn-error.*
27 |
28 | # macOS
29 | .DS_Store
30 | *.pem
31 |
32 | # local env files
33 | .env*.local
34 |
35 | # typescript
36 | *.tsbuildinfo
37 |
38 | app-example
39 | .env
40 |
41 | s3/get-social/*
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM --platform=linux/amd64 node:18
2 | RUN apt-get update -y && apt-get upgrade -y
3 |
4 | RUN apt-get install -y unzip sudo build-essential
5 |
6 | RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
7 | RUN unzip awscliv2.zip
8 | RUN sudo ./aws/install
9 |
10 | WORKDIR /home/app
11 |
12 | COPY . .
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Web Dev Cody
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Common components for Expo apps
2 |
3 | Components that I use in Expo Router apps that are generally optimized for iOS, dark mode, and servers. Main part is the forms which look like Apple's settings app. These should be replaced with proper SwiftUI/Jetpack Compose in the future, but it's still useful to have JS versions for platforms that don't have native support.
4 |
5 |
6 |
7 |
8 |
9 | For best results, just copy the files to another project. Here are the other deps:
10 |
11 | ```
12 | bunx expo install expo-haptics expo-symbols expo-blur expo-web-browser @bacons/apple-colors vaul @react-native-segmented-control/segmented-control
13 | ```
14 |
15 | You can also just bootstrap a project with this repo:
16 |
17 | ```
18 | bunx create-expo -t https://github.com/EvanBacon/expo-router-forms-components
19 | ```
20 |
21 | ## Stack
22 |
23 | Use the correct stack header settings for peak iOS defaults:
24 |
25 | ```tsx
26 | import Stack from "@/components/ui/Stack";
27 | import ThemeProvider from "@/components/ui/ThemeProvider";
28 |
29 | export default function Layout() {
30 | return (
31 |
32 |
37 |
38 | );
39 | }
40 | ```
41 |
42 | Use `headerLargeTitle: true` to get the large header title.
43 |
44 | Use `
` to add a link to the right side of the header with correct hit size and padding for Forms. The default color will be system blue.
45 |
46 | ```tsx
47 | (
51 |
52 | Info
53 |
54 | ),
55 | }}
56 | />
57 | ```
58 |
59 | This stack uses `vaul` on web to make `modal` look like a native modal.
60 |
61 | ## Bottom sheet
62 |
63 | > Works on web too!
64 |
65 |
66 |
67 |
68 | You can open routes as a bottom sheet on iOS:
69 |
70 | ```tsx
71 |
72 | ```
73 |
74 | This sets custom options for [React Native Screens](https://github.com/software-mansion/react-native-screens/blob/main/native-stack/README.md#sheetalloweddetents):
75 |
76 | ```js
77 | {
78 | presentation: "formSheet",
79 | gestureDirection: "vertical",
80 | animation: "slide_from_bottom",
81 | sheetGrabberVisible: true,
82 | sheetInitialDetentIndex: 0,
83 | sheetAllowedDetents: [0.5, 1.0],
84 | }
85 | ```
86 |
87 | - Use `sheetAllowedDetents` to change the snap points of the sheet.
88 | - Change the corder radius with `sheetCornerRadius: 48`.
89 |
90 | ## Tabs
91 |
92 | The custom tabs adds blurry backgrounds and haptics on iOS. You can also use the shortcut `systemImage` to set the icon.
93 |
94 | ```tsx
95 | import ThemeProvider from "@/components/ui/ThemeProvider";
96 |
97 | import Tabs from "@/components/ui/Tabs";
98 |
99 | export default function Layout() {
100 | return (
101 |
102 |
103 |
104 |
105 |
106 |
107 | );
108 | }
109 | ```
110 |
111 | ## Forms
112 |
113 | Start lists with a `` and add sections with ``. Setting `navigationTitle="Settings"` will update the title of the stack header.
114 |
115 | ```tsx
116 |
117 |
118 |
119 | Evan Bacon
120 |
121 | Evan Bacon in browser
122 |
123 |
124 | ```
125 |
126 |
127 | Internals
128 |
129 | Form list is a wrapper around a scroll view with some extra styles and helpers.
130 |
131 | ```tsx
132 |
138 |
139 |
140 | Evan Bacon
141 |
142 | Evan Bacon in browser
143 |
144 |
145 | ```
146 |
147 |
148 |
149 | ## Form Sections
150 |
151 | All top-level children will become items.
152 |
153 | Add `title` and `footer` to a section. These can be strings or React nodes.
154 |
155 | ```tsx
156 | import * as AC from "@bacons/apple-colors";
157 |
158 |
162 | Help improve Search by allowing Apple to store the searches you enter into
163 | Safari, Siri, and Spotlight in a way that is not linked to you.{"\n\n"}
164 | Searches include lookups of general knowledge, and requests to do things like
165 | play music and get directions.{"\n"}
166 |
167 | About Search & Privacy...
168 |
169 |
170 | }
171 | >
172 | Default
173 | ;
174 | ```
175 |
176 | ## Form Items
177 |
178 | - `Form.Text` has extra types for `hint` and custom styles to have adaptive colors for dark mode. The font size is also larger to match the Apple defaults.
179 | - Adds the `systemImage` prop to append an SF Symbol icon before the text. The color of this icon will adopt the color of the text style.
180 |
181 | ```tsx
182 | Hey
183 | ```
184 |
185 | Add a hint to the right-side of the form item:
186 |
187 | ```tsx
188 | Left
189 | ```
190 |
191 | Add a custom press handler to the form item:
192 |
193 | ```tsx
194 | {
196 | console.log("Pressed");
197 | }}
198 | >
199 | Press me
200 |
201 | ```
202 |
203 | You can also use ` ` from React Native similar to SwiftUI:
204 |
205 | ```tsx
206 | console.log("Pressed")} />
207 | ```
208 |
209 | ## Form Link
210 |
211 | Open with in-app browser using `target="_blank"` (only works when the `href` is an external URL):
212 |
213 | ```tsx
214 |
215 | Evan Bacon
216 |
217 | ```
218 |
219 | Add a hint to the right-side of the form item:
220 |
221 | ```tsx
222 |
223 | Evan Bacon
224 |
225 | ```
226 |
227 | Alternatively, use an HStack-type system instead of the `hint` hack:
228 |
229 | ```tsx
230 |
231 | Foo
232 |
233 | Bar
234 |
235 | ```
236 |
237 | Add a quick icon before the text:
238 |
239 | ```tsx
240 |
241 | Evan Bacon
242 |
243 | ```
244 |
245 | Customize the color, size, etc:
246 |
247 | ```tsx
248 |
256 | Evan Bacon
257 |
258 | ```
259 |
260 | ## Hint and wrapping
261 |
262 | Beautifully display a key/value pair with the `hint=""` property. This can also be created manually for extra customization.
263 |
264 |
265 |
266 | The key here is to use `flexShrink` to support floating to the right, then wrapping correctly when the text gets too long.
267 |
268 | Use `flexWrap` to position the text below the title when it gets too long instead of shifting the title down vertically.
269 |
270 | ```tsx
271 |
272 |
273 | Hint
274 |
275 |
276 | {/* Custom */}
277 |
278 | Opening
279 | {/* Spacer */}
280 |
281 | {/* Right */}
282 |
283 | Long list of text that should wrap around when it gets too long
284 |
285 |
286 |
287 | {/* Custom with wrap-below */}
288 |
289 | Opening
290 | {/* Spacer */}
291 |
292 | {/* Right */}
293 |
294 | Long list of text that should wrap around when it gets too long
295 |
296 |
297 |
298 | ```
299 |
300 | ## Form Description and Item
301 |
302 | Add a list item with an image and text + description combo:
303 |
304 | ```tsx
305 |
306 |
314 |
315 | Evan's iPhone
316 | This iPhone 16 Pro Max
317 |
318 |
319 | {/* Spacer */}
320 |
321 |
322 |
323 |
324 | ```
325 |
326 | Create a linkable version like this:
327 |
328 | ```tsx
329 |
330 |
331 | Evan's iPhone
332 | This iPhone 16 Pro Max
333 |
334 |
335 | ```
336 |
337 | ## List Style
338 |
339 | The default `listStyle` is `"auto"` but you can access the old-style with `"grouped"`:
340 |
341 | ```tsx
342 |
343 |
344 |
345 | Evan Bacon
346 |
347 |
348 |
349 | ```
350 |
351 | 
352 |
353 |
354 | ## Colors
355 |
356 | Be sure to use `@bacons/apple-colors` for high-quality P3 colors.
357 |
358 | ## Icons
359 |
360 | Use the `IconSymbol` component to use Apple's SF Symbols.
361 |
362 | ## Status Bar
363 |
364 | Avoid using `` on iOS as the system has built-in support for changing the color better than most custom solutions. Enable OS-changing with:
365 |
366 | ```js
367 | {
368 | "expo": {
369 | "userInterfaceStyle": "automatic",
370 | "ios": {
371 | "infoPlist": {
372 | "UIViewControllerBasedStatusBarAppearance": true,
373 | }
374 | }
375 | }
376 | }
377 | ```
378 |
379 | > This won't work as expected in Expo Go. Use a dev client to understand the behavior better.
380 |
381 | ## Segments
382 |
383 |
384 |
385 |
386 | > `npx expo install @react-native-segmented-control/segmented-control`
387 |
388 | For tabbed content that doesn't belong in the router, use the `Segment` component:
389 |
390 | ```tsx
391 | import {
392 | Segments,
393 | SegmentsList,
394 | SegmentsContent,
395 | SegmentsTrigger,
396 | } from "@/components/ui/Segments";
397 |
398 | export default function Page() {
399 | return (
400 |
401 |
402 | Account
403 | Password
404 |
405 |
406 |
407 | Account Section
408 |
409 |
410 | Password Section
411 |
412 |
413 | );
414 | }
415 | ```
416 |
417 | This can be used with React Server Components as the API is entirely declarative.
418 |
419 | ## Toggle
420 |
421 |
422 |
423 |
424 | Add a toggle switch item using `hint` and `Switch` from React Native:
425 |
426 | ```tsx
427 | }>Label
428 | ```
429 |
430 | You can also build the item manually for more customization:
431 |
432 | ```tsx
433 |
434 | Label
435 |
436 |
437 |
438 | ```
439 |
440 | ## Content Unavailable
441 |
442 | > Similar to SwiftUI's [`ContentUnavailableView`](https://developer.apple.com/documentation/swiftui/contentunavailableview).
443 |
444 | https://github.com/user-attachments/assets/6f3b7baa-3c65-4959-8b84-b585045ef03e
445 |
446 | For empty states, use the ` ` component.
447 |
448 | There are three main uses:
449 |
450 | 1. No search results: ` `. Use search as a string to show the invalid query ` `.
451 | 2. No internet connection: ` `. This shows an animated no connection screen.
452 | 3. Everything else. Use `title`, `description`, and `systemImage` to customize the message. ` `
453 |
454 | Other info:
455 |
456 | - The `systemImage` can be the name of an SF Symbol or a React node. This is useful for custom/animated icons.
457 | - `actions` can be provided for a list of buttons to render under the content, e.g. ` } />`
458 |
--------------------------------------------------------------------------------
/TODO.md:
--------------------------------------------------------------------------------
1 | - [ ] upload profile picture on settings
2 | - [ ] upload picture in post
3 | - [ ] heart a post
4 | - [ ] unheart a post
5 | - [ ] skeleton loader on feed screen
6 | - [ ] delete a post
7 |
--------------------------------------------------------------------------------
/api/followers.tsx:
--------------------------------------------------------------------------------
1 | import { FollowUserResponse } from "@/app/api/followers/index+api";
2 |
3 | export async function followUser(
4 | token: string,
5 | toFollowUserId: string,
6 | unfollow: boolean
7 | ) {
8 | return fetch("/api/followers", {
9 | method: "POST",
10 | headers: {
11 | "Content-Type": "application/json",
12 | Authorization: `Bearer ${token}`,
13 | },
14 | body: JSON.stringify({ toFollowUserId, unfollow }),
15 | }).then(async (response) => {
16 | if (response.ok) {
17 | return (await response.json()) as FollowUserResponse;
18 | } else {
19 | const error = await response.json();
20 | throw new Error(error.error);
21 | }
22 | });
23 | }
24 |
25 | export async function getFollowing(token: string, userId: string) {
26 | return fetch(`/api/followers?userId=${userId}`, {
27 | headers: {
28 | "Content-Type": "application/json",
29 | Authorization: `Bearer ${token}`,
30 | },
31 | }).then(async (response) => {
32 | if (response.ok) {
33 | return (await response.json()) as FollowUserResponse | undefined;
34 | } else {
35 | const error = await response.json();
36 | throw new Error(error.error);
37 | }
38 | });
39 | }
40 |
--------------------------------------------------------------------------------
/api/notifications.tsx:
--------------------------------------------------------------------------------
1 | import { NotificationResponse } from "@/app/api/notifications+api";
2 |
3 | export async function getNotifications(token: string) {
4 | return fetch(`/api/notifications`, {
5 | headers: {
6 | "Content-Type": "application/json",
7 | Authorization: `Bearer ${token}`,
8 | },
9 | }).then(async (response) => {
10 | if (response.ok) {
11 | return (await response.json()) as NotificationResponse;
12 | } else {
13 | const error = await response.json();
14 | throw new Error(error.error);
15 | }
16 | });
17 | }
18 |
--------------------------------------------------------------------------------
/api/posts.tsx:
--------------------------------------------------------------------------------
1 | import { GetPostResponse } from "@/app/api/posts+api";
2 |
3 | export async function createPost(text: string, token: string) {
4 | return fetch("/api/posts", {
5 | method: "POST",
6 | body: JSON.stringify({ text }),
7 | headers: {
8 | "Content-Type": "application/json",
9 | Authorization: `Bearer ${token}`,
10 | },
11 | }).then(async (response) => {
12 | if (response.ok) {
13 | return await response.json();
14 | } else {
15 | const error = await response.json();
16 | throw new Error(error.error);
17 | }
18 | });
19 | }
20 |
21 | export async function getPosts(token: string) {
22 | return fetch("/api/posts", {
23 | method: "GET",
24 | headers: {
25 | "Content-Type": "application/json",
26 | Authorization: `Bearer ${token}`,
27 | },
28 | }).then(async (response) => {
29 | if (response.ok) {
30 | return (await response.json()) as GetPostResponse;
31 | } else {
32 | const error = await response.json();
33 | throw new Error(error.error);
34 | }
35 | });
36 | }
37 |
--------------------------------------------------------------------------------
/api/profiles.tsx:
--------------------------------------------------------------------------------
1 | import { ProfileResponse } from "@/app/api/profiles/[userId]+api";
2 | import { Profile } from "@/db/schema";
3 |
4 | export async function getMyProfile(token: string): Promise {
5 | return fetch("/api/profiles", {
6 | method: "GET",
7 | headers: {
8 | Authorization: `Bearer ${token}`,
9 | },
10 | }).then(async (response) => {
11 | if (response.ok) {
12 | return await response.json();
13 | } else {
14 | const error = await response.json();
15 | throw new Error(error.error);
16 | }
17 | });
18 | }
19 |
20 | export async function updateProfile(displayName: string, token: string) {
21 | return fetch("/api/profiles", {
22 | method: "PUT",
23 | headers: {
24 | Authorization: `Bearer ${token}`,
25 | },
26 | body: JSON.stringify({ displayName }),
27 | }).then(async (response) => {
28 | if (response.ok) {
29 | return await response.json();
30 | } else {
31 | const error = await response.json();
32 | throw new Error(error.error);
33 | }
34 | });
35 | }
36 |
37 | export async function updateProfileImage(imageId: string, token: string) {
38 | return fetch("/api/profile-image", {
39 | method: "PUT",
40 | headers: {
41 | Authorization: `Bearer ${token}`,
42 | },
43 | body: JSON.stringify({ imageId }),
44 | }).then(async (response) => {
45 | if (response.ok) {
46 | return await response.json();
47 | } else {
48 | const error = await response.json();
49 | throw new Error(error.error);
50 | }
51 | });
52 | }
53 |
54 | export async function getProfile(
55 | userId: string,
56 | token: string
57 | ): Promise {
58 | return fetch(`/api/profiles/${userId}`, {
59 | method: "GET",
60 | headers: {
61 | Authorization: `Bearer ${token}`,
62 | },
63 | }).then(async (response) => {
64 | if (response.ok) {
65 | return await response.json();
66 | } else {
67 | const error = await response.json();
68 | throw new Error(error.error);
69 | }
70 | });
71 | }
72 |
--------------------------------------------------------------------------------
/api/storage.tsx:
--------------------------------------------------------------------------------
1 | export async function uploadFile(file: string, token: string) {
2 | const { url, fields, id } = await fetch("/api/presigned-post", {
3 | method: "POST",
4 | headers: {
5 | "Content-Type": "application/json",
6 | Authorization: `Bearer ${token}`,
7 | },
8 | }).then((res) => res.json());
9 |
10 | const formData = new FormData();
11 | Object.entries(fields).forEach(([key, value]) => {
12 | formData.append(key, value as string);
13 | });
14 |
15 | formData.append("file", {
16 | uri: file,
17 | type: "image/jpeg",
18 | name: "upload.jpg",
19 | } as any);
20 |
21 | const uploadResponse = await fetch(url, {
22 | method: "POST",
23 | body: formData,
24 | });
25 |
26 | if (!uploadResponse.ok) {
27 | throw new Error("Failed to upload video");
28 | }
29 |
30 | return id;
31 | }
32 |
--------------------------------------------------------------------------------
/api/users.tsx:
--------------------------------------------------------------------------------
1 | import { Post } from "@/db/schema";
2 |
3 | export async function signUp(email: string, password: string) {
4 | return fetch("/api/sign-up", {
5 | method: "POST",
6 | body: JSON.stringify({ email, password }),
7 | }).then(async (response) => {
8 | if (response.ok) {
9 | return await response.json();
10 | } else {
11 | const error = await response.json();
12 | throw new Error(error.error);
13 | }
14 | });
15 | }
16 |
17 | export async function signIn(email: string, password: string) {
18 | return fetch("/api/sign-in", {
19 | method: "POST",
20 | body: JSON.stringify({ email, password }),
21 | }).then(async (response) => {
22 | if (response.ok) {
23 | return await response.json();
24 | } else {
25 | const error = await response.json();
26 | throw new Error(error.error);
27 | }
28 | });
29 | }
30 |
31 | export async function getUserPosts(
32 | userId: string,
33 | token: string
34 | ): Promise {
35 | return fetch(`/api/users/${userId}/posts`, {
36 | method: "GET",
37 | headers: {
38 | Authorization: `Bearer ${token}`,
39 | },
40 | }).then(async (response) => {
41 | if (response.ok) {
42 | return await response.json();
43 | } else {
44 | const error = await response.json();
45 | throw new Error(error.error);
46 | }
47 | });
48 | }
49 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "get-social",
4 | "slug": "get-social",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/images/icon.png",
8 | "scheme": "myapp",
9 | "userInterfaceStyle": "automatic",
10 | "newArchEnabled": true,
11 | "ios": {
12 | "supportsTablet": true,
13 | "bundleIdentifier": "com.bacon.bacomponents",
14 | "infoPlist": {
15 | "UIViewControllerBasedStatusBarAppearance": true,
16 | "ITSAppUsesNonExemptEncryption": false
17 | }
18 | },
19 | "android": {
20 | "adaptiveIcon": {
21 | "foregroundImage": "./assets/images/adaptive-icon.png",
22 | "backgroundColor": "#ffffff"
23 | }
24 | },
25 | "web": {
26 | "bundler": "metro",
27 | "output": "server",
28 | "favicon": "./assets/images/favicon.png"
29 | },
30 | "plugins": [
31 | [
32 | "expo-router",
33 | {
34 | "origin": "https://get-social.expo.app"
35 | }
36 | ],
37 | [
38 | "expo-secure-store",
39 | {
40 | "configureAndroidBackup": true,
41 | "faceIDPermission": "Allow $(PRODUCT_NAME) to access your Face ID biometric data."
42 | }
43 | ],
44 | [
45 | "expo-splash-screen",
46 | {
47 | "image": "./assets/images/splash-icon.png",
48 | "imageWidth": 200,
49 | "resizeMode": "contain",
50 | "backgroundColor": "#ffffff"
51 | }
52 | ],
53 | "expo-sqlite",
54 | "expo-font"
55 | ],
56 | "experiments": {
57 | "typedRoutes": true
58 | },
59 | "extra": {
60 | "eas": {
61 | "projectId": "d849c2c8-d315-4c81-8e26-a2eb1c39a94e"
62 | },
63 | "router": {
64 | "origin": "https://api.routes.expo.app"
65 | }
66 | },
67 | "owner": "webdevcody"
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/app/(auth)/_layout.tsx:
--------------------------------------------------------------------------------
1 | import Stack from "@/components/ui/Stack";
2 |
3 | export default function Layout() {
4 | return (
5 |
6 |
13 |
20 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/app/(auth)/index.tsx:
--------------------------------------------------------------------------------
1 | import { AppButton } from "@/components/ui/AppButton";
2 | import { Text } from "@/components/ui/Form";
3 | import { useAuth } from "@/hooks/useAuth";
4 | import { useRouter } from "expo-router";
5 | import React from "react";
6 | import { ActivityIndicator, Image, StyleSheet, View } from "react-native";
7 |
8 | export default function Page() {
9 | const router = useRouter();
10 |
11 | const { isLoggedIn, isLoading } = useAuth();
12 |
13 | return (
14 |
15 |
19 |
20 | Let's GetSocial
21 |
22 | {isLoading ? (
23 |
24 | ) : isLoggedIn ? (
25 | router.push("/dashboard/(dashboard)")}>
26 | Go to Dashboard
27 |
28 | ) : (
29 |
30 | router.push("/sign-in")}>Sign In
31 | router.push("/sign-up")}
34 | >
35 | Sign Up
36 |
37 |
38 | )}
39 |
40 | );
41 | }
42 |
43 | const styles = StyleSheet.create({
44 | container: {
45 | backgroundColor: "#1E1E2F",
46 | flex: 1,
47 | justifyContent: "center",
48 | alignItems: "center",
49 | gap: 24,
50 | },
51 | logo: {
52 | width: 240,
53 | height: 200,
54 | borderRadius: 10,
55 | },
56 | title: {
57 | fontSize: 32,
58 | fontWeight: "bold",
59 | },
60 | signUpButton: {
61 | backgroundColor: "gray",
62 | },
63 | });
64 |
--------------------------------------------------------------------------------
/app/(auth)/sign-in.tsx:
--------------------------------------------------------------------------------
1 | import { signIn } from "@/api/users";
2 | import { AppButton } from "@/components/ui/AppButton";
3 | import { Text } from "@/components/ui/Form";
4 | import { secureSave } from "@/utils/storage";
5 | import { useRouter } from "expo-router";
6 | import { useState } from "react";
7 | import { StyleSheet, TextInput, View } from "react-native";
8 |
9 | export default function Page() {
10 | const router = useRouter();
11 | const [email, setEmail] = useState("");
12 | const [password, setPassword] = useState("");
13 |
14 | function handleSignUp() {
15 | if (!email || !password) {
16 | alert("Please fill in all fields");
17 | return;
18 | }
19 |
20 | signIn(email, password)
21 | .then(async ({ token }) => {
22 | await secureSave("token", token);
23 | setPassword("");
24 | setEmail("");
25 | router.push("/dashboard/(dashboard)");
26 | })
27 | .catch((error) => {
28 | alert(error.message);
29 | });
30 | }
31 |
32 | return (
33 |
34 |
35 |
36 | Email
37 |
43 |
44 |
45 |
46 | Password
47 |
54 |
55 |
56 | Sign In
57 |
58 |
59 | );
60 | }
61 |
62 | const styles = StyleSheet.create({
63 | container: {
64 | backgroundColor: "#15152F",
65 | flex: 1,
66 | justifyContent: "center",
67 | alignItems: "center",
68 | gap: 12,
69 | },
70 | title: {
71 | fontSize: 32,
72 | fontWeight: "bold",
73 | },
74 | field: {
75 | gap: 12,
76 | },
77 | label: {
78 | fontSize: 16,
79 | textAlign: "left",
80 | },
81 | form: {
82 | gap: 24,
83 | width: "80%",
84 | },
85 | input: {
86 | backgroundColor: "#FFFFFF",
87 | padding: 12,
88 | borderRadius: 8,
89 | fontSize: 16,
90 | color: "#000000",
91 | borderWidth: 1,
92 | borderColor: "#CCCCCC",
93 | },
94 | });
95 |
--------------------------------------------------------------------------------
/app/(auth)/sign-up.tsx:
--------------------------------------------------------------------------------
1 | import { signUp } from "@/api/users";
2 | import { AppButton } from "@/components/ui/AppButton";
3 | import { Text } from "@/components/ui/Form";
4 | import { secureSave } from "@/utils/storage";
5 | import { useRouter } from "expo-router";
6 | import { useState } from "react";
7 | import { StyleSheet, TextInput, View } from "react-native";
8 |
9 | export default function Page() {
10 | const router = useRouter();
11 | const [email, setEmail] = useState("");
12 | const [password, setPassword] = useState("");
13 |
14 | function handleSignUp() {
15 | if (!email || !password) {
16 | alert("Please fill in all fields");
17 | return;
18 | }
19 |
20 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
21 | if (!emailRegex.test(email)) {
22 | alert("Please enter a valid email address");
23 | return;
24 | }
25 |
26 | if (password.length < 8) {
27 | alert("Password must be at least 8 characters long");
28 | return;
29 | }
30 |
31 | signUp(email, password)
32 | .then(async ({ token }) => {
33 | await secureSave("token", token);
34 | router.push("/dashboard/(dashboard)");
35 | setPassword("");
36 | setEmail("");
37 | })
38 | .catch((error) => {
39 | alert(error.message);
40 | });
41 | }
42 |
43 | return (
44 |
45 |
46 |
47 | Email
48 |
54 |
55 |
56 |
57 | Password
58 |
65 |
66 |
67 | Sign Up
68 |
69 |
70 | );
71 | }
72 |
73 | const styles = StyleSheet.create({
74 | container: {
75 | backgroundColor: "#15152F",
76 | flex: 1,
77 | justifyContent: "center",
78 | alignItems: "center",
79 | gap: 12,
80 | },
81 | title: {
82 | fontSize: 32,
83 | fontWeight: "bold",
84 | },
85 | field: {
86 | gap: 12,
87 | },
88 | label: {
89 | fontSize: 16,
90 | textAlign: "left",
91 | },
92 | form: {
93 | gap: 24,
94 | width: "80%",
95 | },
96 | input: {
97 | backgroundColor: "#FFFFFF",
98 | padding: 12,
99 | borderRadius: 8,
100 | fontSize: 16,
101 | color: "#000000",
102 | borderWidth: 1,
103 | borderColor: "#CCCCCC",
104 | },
105 | });
106 |
--------------------------------------------------------------------------------
/app/+html.tsx:
--------------------------------------------------------------------------------
1 | import { ScrollViewStyleReset } from "expo-router/html";
2 | import { type PropsWithChildren } from "react";
3 |
4 | // This file is web-only and used to configure the root HTML for every
5 | // web page during static rendering.
6 | // The contents of this function only run in Node.js environments and
7 | // do not have access to the DOM or browser APIs.
8 | export default function Root({ children }: PropsWithChildren) {
9 | return (
10 |
11 |
12 |
13 |
14 |
18 |
19 | {/*
20 | Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
21 | However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
22 | */}
23 |
24 | {/* */}
30 |
31 | {/* Add any additional elements that you want globally available on web... */}
32 |
33 | {children}
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/app/_layout.tsx:
--------------------------------------------------------------------------------
1 | import ThemeProvider from "@/components/ui/ThemeProvider";
2 | import { AuthProvider } from "@/hooks/useAuth";
3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4 | import { Slot } from "expo-router";
5 |
6 | const queryClient = new QueryClient();
7 |
8 | export default function Layout() {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/app/api/followers/index+api.tsx:
--------------------------------------------------------------------------------
1 | import { db } from "@/db";
2 | import { eq } from "drizzle-orm";
3 | import {
4 | Follower,
5 | followers,
6 | notifications,
7 | profiles,
8 | User,
9 | } from "@/db/schema";
10 | import { withAuth } from "@/utils/withAuth";
11 | import { and } from "drizzle-orm";
12 |
13 | export type FollowUserResponse = Follower;
14 |
15 | export const GET = withAuth(async (request: Request, user: User) => {
16 | const url = new URL(request.url);
17 | const userId = url.searchParams.get("userId");
18 |
19 | if (!userId) {
20 | return Response.json({ error: "User ID is required" }, { status: 400 });
21 | }
22 |
23 | const following = await db.query.followers.findFirst({
24 | where: and(
25 | eq(followers.userId, user.id),
26 | eq(followers.followingId, userId)
27 | ),
28 | });
29 |
30 | return Response.json(!!following, { status: 200 });
31 | });
32 |
33 | export const POST = withAuth(async (request: Request, user: User) => {
34 | const { toFollowUserId, unfollow } = await request.json();
35 |
36 | if (!toFollowUserId) {
37 | return Response.json({ error: "User ID is required" }, { status: 400 });
38 | }
39 |
40 | const profile = await db.query.profiles.findFirst({
41 | where: eq(profiles.userId, user.id),
42 | });
43 |
44 | if (!profile) {
45 | return Response.json({ error: "Profile not found" }, { status: 400 });
46 | }
47 |
48 | if (unfollow) {
49 | await db
50 | .delete(followers)
51 | .where(
52 | and(
53 | eq(followers.userId, user.id),
54 | eq(followers.followingId, toFollowUserId)
55 | )
56 | )
57 | .returning();
58 | return Response.json({ following: undefined }, { status: 200 });
59 | } else {
60 | const following = await db.query.followers.findFirst({
61 | where: and(
62 | eq(followers.userId, user.id),
63 | eq(followers.followingId, toFollowUserId)
64 | ),
65 | });
66 |
67 | if (following) {
68 | return Response.json({ error: "Already following" }, { status: 400 });
69 | }
70 |
71 | const follower = await db.insert(followers).values({
72 | userId: user.id,
73 | followingId: toFollowUserId,
74 | });
75 |
76 | await db.insert(notifications).values({
77 | userId: toFollowUserId,
78 | fromUserId: user.id,
79 | type: "follow",
80 | content: `${profile.displayName} followed you`,
81 | });
82 |
83 | return Response.json({ follower }, { status: 200 });
84 | }
85 | });
86 |
--------------------------------------------------------------------------------
/app/api/notifications+api.tsx:
--------------------------------------------------------------------------------
1 | import { withAuth } from "@/utils/withAuth";
2 | import { notifications, User } from "@/db/schema";
3 | import { db } from "@/db";
4 | import { desc, eq } from "drizzle-orm";
5 | import { Notification } from "@/db/schema";
6 |
7 | export type NotificationResponse = Notification[];
8 |
9 | export const GET = withAuth(async (request: Request, user: User) => {
10 | const userNotifications = await db.query.notifications.findMany({
11 | where: eq(notifications.userId, user.id),
12 | orderBy: [desc(notifications.createdAt)],
13 | limit: 20,
14 | });
15 |
16 | return Response.json(userNotifications);
17 | });
18 |
--------------------------------------------------------------------------------
/app/api/posts+api.tsx:
--------------------------------------------------------------------------------
1 | import jwt from "jsonwebtoken";
2 | import { db } from "@/db";
3 | import { desc, eq } from "drizzle-orm";
4 | import {
5 | followers,
6 | notifications,
7 | Post,
8 | posts,
9 | profiles,
10 | User,
11 | users,
12 | } from "@/db/schema";
13 | import { withAuth } from "@/utils/withAuth";
14 |
15 | export type GetPostResponse = (Post & {
16 | profile: {
17 | displayName: string;
18 | imageId: string;
19 | };
20 | })[];
21 |
22 | export const GET = withAuth(async (request: Request, user: User) => {
23 | const allPosts = await db.query.posts.findMany({
24 | orderBy: [desc(posts.createdAt)],
25 | limit: 20,
26 | with: {
27 | profile: {
28 | columns: {
29 | displayName: true,
30 | imageId: true,
31 | },
32 | },
33 | },
34 | });
35 |
36 | return Response.json(allPosts);
37 | });
38 |
39 | export const POST = withAuth(async (request: Request, user: User) => {
40 | const { text } = await request.json();
41 |
42 | if (!text) {
43 | return Response.json(
44 | { error: "Your post requires some text" },
45 | { status: 400 }
46 | );
47 | }
48 |
49 | const profile = await db.query.profiles.findFirst({
50 | where: eq(profiles.userId, user.id),
51 | });
52 |
53 | if (!profile) {
54 | return Response.json({ error: "Profile not found" }, { status: 400 });
55 | }
56 |
57 | const [newPost] = await db
58 | .insert(posts)
59 | .values({
60 | text,
61 | userId: user.id,
62 | })
63 | .returning();
64 |
65 | const userFollowers = await db.query.followers.findMany({
66 | where: eq(followers.followingId, user.id),
67 | });
68 |
69 | await Promise.all(
70 | userFollowers.map((follower) =>
71 | db.insert(notifications).values({
72 | userId: follower.userId,
73 | fromUserId: user.id,
74 | type: "post",
75 | content: `${profile.displayName} posted: ${text.substring(0, 100)}...`,
76 | })
77 | )
78 | );
79 |
80 | return Response.json(newPost);
81 | });
82 |
--------------------------------------------------------------------------------
/app/api/presigned-post+api.tsx:
--------------------------------------------------------------------------------
1 | import { S3Client } from "@aws-sdk/client-s3";
2 | import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
3 | import { v4 as uuidv4 } from "uuid";
4 | import { withAuth } from "@/utils/withAuth";
5 |
6 | const isLocalS3 = process.env.USE_LOCAL_S3 === "true";
7 |
8 | export const s3Client = new S3Client({
9 | region: "us-east-1",
10 | ...(isLocalS3 && {
11 | endpoint: "http://localhost:9000",
12 | credentials: {
13 | accessKeyId: "S3RVER",
14 | secretAccessKey: "S3RVER",
15 | },
16 | forcePathStyle: true,
17 | }),
18 | });
19 |
20 | export const POST = withAuth(async (request: Request) => {
21 | const id = uuidv4();
22 |
23 | const { url, fields } = await createPresignedPost(s3Client, {
24 | Bucket: process.env.STORAGE_BUCKET_NAME!,
25 | Key: id,
26 | Conditions: [
27 | ["content-length-range", 0, 5 * 1024 * 1024],
28 | ["starts-with", "$Content-Type", "image/"],
29 | ],
30 | Expires: 600,
31 | });
32 |
33 | const finalUrl =
34 | process.env.NODE_ENV === "development"
35 | ? `http://localhost:9000/${process.env.STORAGE_BUCKET_NAME}/`
36 | : url;
37 |
38 | return new Response(JSON.stringify({ url: finalUrl, fields, id }), {
39 | status: 200,
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/app/api/profile-image+api.tsx:
--------------------------------------------------------------------------------
1 | import { profiles } from "@/db/schema";
2 | import { db } from "@/db";
3 | import { User } from "@/db/schema";
4 | import { withAuth } from "@/utils/withAuth";
5 | import { eq } from "drizzle-orm";
6 |
7 | export const PUT = withAuth(async (request: Request, user: User) => {
8 | const { imageId } = await request.json();
9 |
10 | if (!imageId) {
11 | return Response.json({ error: "Image ID is required" }, { status: 400 });
12 | }
13 |
14 | const profile = await db
15 | .update(profiles)
16 | .set({ imageId })
17 | .where(eq(profiles.userId, user.id));
18 |
19 | return Response.json(profile);
20 | });
21 |
--------------------------------------------------------------------------------
/app/api/profiles+api.tsx:
--------------------------------------------------------------------------------
1 | import { profiles } from "@/db/schema";
2 | import { db } from "@/db";
3 | import { User } from "@/db/schema";
4 | import { withAuth } from "@/utils/withAuth";
5 | import { eq } from "drizzle-orm";
6 |
7 | export const GET = withAuth(async (request: Request, user: User) => {
8 | const profile = await db.query.profiles.findFirst({
9 | where: eq(profiles.userId, user.id),
10 | });
11 |
12 | return Response.json(profile);
13 | });
14 |
15 | export const PUT = withAuth(async (request: Request, user: User) => {
16 | const { displayName } = await request.json();
17 |
18 | if (!displayName) {
19 | return Response.json(
20 | { error: "Display name is required" },
21 | { status: 400 }
22 | );
23 | }
24 |
25 | const profile = await db
26 | .update(profiles)
27 | .set({ displayName })
28 | .where(eq(profiles.userId, user.id));
29 |
30 | return Response.json(profile);
31 | });
32 |
--------------------------------------------------------------------------------
/app/api/profiles/[userId]+api.tsx:
--------------------------------------------------------------------------------
1 | import { followers, Profile, profiles } from "@/db/schema";
2 | import { db } from "@/db";
3 | import { User } from "@/db/schema";
4 | import { withAuth } from "@/utils/withAuth";
5 | import { count, eq } from "drizzle-orm";
6 |
7 | export type ProfileResponse = Profile & {
8 | followingCount: number;
9 | followersCount: number;
10 | };
11 |
12 | export const GET = withAuth(async (request: Request, user: User) => {
13 | const userId = request.url.split("/").pop();
14 |
15 | if (!userId) {
16 | return Response.json({ error: "User ID is required" }, { status: 400 });
17 | }
18 |
19 | const profile = await db.query.profiles.findFirst({
20 | where: eq(profiles.userId, userId),
21 | });
22 |
23 | const followingCount = await db
24 | .select({ count: count() })
25 | .from(followers)
26 | .where(eq(followers.userId, userId));
27 |
28 | const followersCount = await db
29 | .select({ count: count() })
30 | .from(followers)
31 | .where(eq(followers.followingId, userId));
32 |
33 | return Response.json({
34 | ...profile,
35 | followingCount: followingCount[0].count,
36 | followersCount: followersCount[0].count,
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/app/api/sign-in+api.tsx:
--------------------------------------------------------------------------------
1 | import { db } from "@/db";
2 | import { eq } from "drizzle-orm";
3 | import { users } from "@/db/schema";
4 | import { generateJwt, hashPassword } from "@/utils/auth";
5 |
6 | export async function POST(request: Request) {
7 | const { email, password } = await request.json();
8 |
9 | const user = await db.query.users.findFirst({
10 | where: eq(users.email, email),
11 | });
12 |
13 | if (!user) {
14 | return Response.json({ error: "User does not exist" }, { status: 400 });
15 | }
16 |
17 | const hashedPassword = await hashPassword(password);
18 |
19 | if (user.password !== hashedPassword) {
20 | return Response.json({ error: "Invalid password" }, { status: 400 });
21 | }
22 |
23 | const token = await generateJwt(user.id);
24 |
25 | return Response.json({ token });
26 | }
27 |
--------------------------------------------------------------------------------
/app/api/sign-up+api.tsx:
--------------------------------------------------------------------------------
1 | import { db } from "@/db";
2 | import { eq } from "drizzle-orm";
3 | import { profiles, users } from "@/db/schema";
4 | import { generateJwt, hashPassword } from "@/utils/auth";
5 | import { generateUsername } from "unique-username-generator";
6 |
7 | export async function POST(request: Request) {
8 | const { email, password } = await request.json();
9 |
10 | // 1. validate email and password
11 | if (!email || !password) {
12 | return Response.json(
13 | { error: "Email and password are required" },
14 | { status: 400 }
15 | );
16 | }
17 |
18 | if (password.length < 8) {
19 | return Response.json(
20 | { error: "Password must be at least 8 characters long" },
21 | { status: 400 }
22 | );
23 | }
24 |
25 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
26 | if (!emailRegex.test(email)) {
27 | return Response.json({ error: "Invalid email address" }, { status: 400 });
28 | }
29 |
30 | // 2. look up user by email in db
31 | const user = await db.query.users.findFirst({
32 | where: eq(users.email, email),
33 | });
34 |
35 | // 3. if user exists, return error
36 | if (user) {
37 | return Response.json({ error: "User already exists" }, { status: 400 });
38 | }
39 |
40 | // Hash password using Web Crypto API
41 | const hashedPassword = await hashPassword(password);
42 |
43 | // 4. create user in db
44 | const [newUser] = await db
45 | .insert(users)
46 | .values({
47 | email,
48 | password: hashedPassword,
49 | })
50 | .returning();
51 |
52 | // 4a. create the profile
53 | const displayName = generateUsername(" ");
54 | await db.insert(profiles).values({
55 | userId: newUser.id,
56 | displayName,
57 | });
58 |
59 | // 5. create jwt token
60 | const token = await generateJwt(newUser.id);
61 |
62 | // 6. return token
63 | return Response.json({ token });
64 | }
65 |
--------------------------------------------------------------------------------
/app/api/users/[userId]/posts+api.tsx:
--------------------------------------------------------------------------------
1 | import { posts, profiles } from "@/db/schema";
2 | import { db } from "@/db";
3 | import { User } from "@/db/schema";
4 | import { withAuth } from "@/utils/withAuth";
5 | import { desc, eq } from "drizzle-orm";
6 |
7 | export const GET = withAuth(async (request: Request, user: User) => {
8 | const parts = request.url.split("/");
9 | const userId = parts[parts.length - 2];
10 |
11 | if (!userId) {
12 | return Response.json({ error: "User ID is required" }, { status: 400 });
13 | }
14 |
15 | const userPosts = await db.query.posts.findMany({
16 | where: eq(posts.userId, userId),
17 | orderBy: [desc(posts.createdAt)],
18 | limit: 20,
19 | });
20 |
21 | return Response.json(userPosts);
22 | });
23 |
--------------------------------------------------------------------------------
/app/dashboard/(dashboard,post,notifications,account)/_layout.tsx:
--------------------------------------------------------------------------------
1 | import Stack from "@/components/ui/Stack";
2 | import { useRouter } from "expo-router";
3 | import { Button } from "react-native";
4 |
5 | export default function Layout() {
6 | const router = useRouter();
7 |
8 | return (
9 |
10 |
17 |
18 | (
24 | router.back()} />
25 | ),
26 | }}
27 | />
28 | (
34 | router.back()} />
35 | ),
36 | }}
37 | />
38 |
39 | (
45 | router.back()} />
46 | ),
47 | }}
48 | />
49 |
50 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/app/dashboard/(dashboard,post,notifications,account)/account.tsx:
--------------------------------------------------------------------------------
1 | import { secureDelete } from "@/utils/storage";
2 | import { useRouter } from "expo-router";
3 | import {
4 | ActivityIndicator,
5 | Button,
6 | Image,
7 | Pressable,
8 | StyleSheet,
9 | TextInput,
10 | View,
11 | } from "react-native";
12 | import * as Form from "@/components/ui/Form";
13 | import { IconSymbol } from "@/components/ui/IconSymbol";
14 | import * as AC from "@bacons/apple-colors";
15 | import { useQuery, useQueryClient } from "@tanstack/react-query";
16 | import {
17 | getMyProfile,
18 | updateProfile,
19 | updateProfileImage,
20 | } from "@/api/profiles";
21 | import { useAuth } from "@/hooks/useAuth";
22 | import { useEffect, useState } from "react";
23 | import * as ImagePicker from "expo-image-picker";
24 | import { uploadFile } from "@/api/storage";
25 | import { getImageUrl } from "@/utils/images";
26 |
27 | export default function Page() {
28 | const router = useRouter();
29 | const { token } = useAuth();
30 | const [isSaving, setIsSaving] = useState(false);
31 |
32 | const { data: profile, isLoading: isLoadingProfile } = useQuery({
33 | queryKey: ["profile"],
34 | queryFn: () => getMyProfile(token),
35 | });
36 |
37 | const queryClient = useQueryClient();
38 |
39 | const [displayName, setDisplayName] = useState("");
40 |
41 | useEffect(() => {
42 | setDisplayName(profile?.displayName ?? "");
43 | }, [profile]);
44 |
45 | async function handleUpdateProfile() {
46 | if (!displayName) {
47 | return;
48 | }
49 |
50 | setIsSaving(true);
51 | setTimeout(() => {
52 | setIsSaving(false);
53 | }, 1400);
54 |
55 | await updateProfile(displayName, token).catch((error) => {
56 | console.error(error);
57 | });
58 | }
59 |
60 | async function handleChangePicture() {
61 | const result = await ImagePicker.launchImageLibraryAsync({
62 | mediaTypes: ImagePicker.MediaTypeOptions.Images,
63 | allowsEditing: true,
64 | aspect: [1, 1],
65 | });
66 |
67 | if (!result.canceled) {
68 | const selectedImage = result.assets[0].uri;
69 | const fileId = await uploadFile(selectedImage, token);
70 | await updateProfileImage(fileId, token).then(() => {
71 | queryClient.invalidateQueries({ queryKey: ["profile"] });
72 | queryClient.invalidateQueries({ queryKey: ["posts"] });
73 | queryClient.invalidateQueries({
74 | queryKey: ["profile", profile?.userId],
75 | });
76 | });
77 | }
78 | }
79 |
80 | return (
81 |
82 |
83 |
84 |
85 | {profile?.imageId && (
86 |
90 | )}
91 |
92 |
93 | Change Profile Picture
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 | {isLoadingProfile ? (
102 |
103 | ) : (
104 |
109 | )}
110 | {!isLoadingProfile && (
111 | {
113 | handleUpdateProfile();
114 | }}
115 | >
116 |
121 |
122 | )}
123 |
124 |
125 |
126 |
127 |
132 | {
134 | secureDelete("token");
135 | router.replace("/");
136 | }}
137 | >
138 | Sign Out
139 |
140 |
141 |
142 |
143 | );
144 | }
145 |
146 | const styles = StyleSheet.create({
147 | input: {
148 | flex: 1,
149 | borderWidth: 1,
150 | color: AC.lightText,
151 | borderColor: AC.lightText,
152 | borderRadius: 8,
153 | padding: 8,
154 | },
155 | avatarImage: {
156 | width: 100,
157 | height: 100,
158 | borderRadius: 50,
159 | },
160 | avatarContainer: {
161 | alignItems: "center",
162 | justifyContent: "center",
163 | },
164 | avatar: {
165 | width: 100,
166 | height: 100,
167 | borderRadius: 50,
168 | backgroundColor: AC.lightText,
169 | },
170 | });
171 |
--------------------------------------------------------------------------------
/app/dashboard/(dashboard,post,notifications,account)/index.tsx:
--------------------------------------------------------------------------------
1 | import { getPosts } from "@/api/posts";
2 | import { BodyScrollView } from "@/components/ui/BodyScrollView";
3 | import { Text } from "@/components/ui/Form";
4 | import { useAuth } from "@/hooks/useAuth";
5 | import { useQuery, useQueryClient } from "@tanstack/react-query";
6 | import {
7 | Image,
8 | Pressable,
9 | RefreshControl,
10 | StyleSheet,
11 | View,
12 | } from "react-native";
13 | import { formatDistanceToNow } from "date-fns";
14 | import { GetPostResponse } from "@/app/api/posts+api";
15 | import { useRouter } from "expo-router";
16 | import { getImageUrl } from "@/utils/images";
17 |
18 | function SkeletonPost() {
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
34 | export default function Page() {
35 | const { token } = useAuth();
36 |
37 | const {
38 | data: posts,
39 | isLoading,
40 | isRefetching,
41 | } = useQuery({
42 | queryKey: ["posts"],
43 | queryFn: () => getPosts(token),
44 | });
45 |
46 | const queryClient = useQueryClient();
47 |
48 | function handleRefresh() {
49 | queryClient.invalidateQueries({ queryKey: ["posts"] });
50 | }
51 |
52 | return (
53 |
57 | }
58 | >
59 | {isLoading && (
60 | <>
61 |
62 |
63 |
64 | >
65 | )}
66 | {posts?.map((post) => (
67 |
68 | ))}
69 |
70 | );
71 | }
72 |
73 | function PostComponent({ post }: { post: GetPostResponse[number] }) {
74 | const router = useRouter();
75 |
76 | const avatarUrl = post.profile.imageId
77 | ? getImageUrl(post.profile.imageId)
78 | : `https://ui-avatars.com/api/?name=${encodeURIComponent(
79 | post.profile.displayName
80 | )}&background=random`;
81 |
82 | return (
83 |
84 |
85 |
86 |
87 | router.push(`/dashboard/profile/${post.userId}`)}
89 | >
90 | {post.profile.displayName}
91 |
92 |
93 | {formatDistanceToNow(new Date(post.createdAt), {
94 | addSuffix: true,
95 | })}
96 |
97 |
98 |
99 | {post.text}
100 |
101 | );
102 | }
103 |
104 | const styles = StyleSheet.create({
105 | container: {
106 | marginTop: 100,
107 | },
108 | postContainer: {
109 | backgroundColor: "#111",
110 | padding: 16,
111 | borderRadius: 8,
112 | marginHorizontal: 16,
113 | marginBottom: 16,
114 | },
115 | displayName: {
116 | fontSize: 16,
117 | fontWeight: "bold",
118 | },
119 | avatarImage: {
120 | width: 40,
121 | height: 40,
122 | borderRadius: 20,
123 | marginRight: 12,
124 | },
125 | postHeader: {
126 | flexDirection: "row",
127 | alignItems: "center",
128 | marginBottom: 12,
129 | },
130 | timestamp: {
131 | color: "#666666",
132 | fontSize: 14,
133 | },
134 | postText: {
135 | fontSize: 16,
136 | lineHeight: 24,
137 | },
138 | skeleton: {
139 | backgroundColor: "#222",
140 | opacity: 0.7,
141 | },
142 | skeletonDisplayName: {
143 | height: 16,
144 | width: 120,
145 | borderRadius: 4,
146 | marginBottom: 4,
147 | },
148 | skeletonTimestamp: {
149 | height: 14,
150 | width: 80,
151 | borderRadius: 4,
152 | },
153 | skeletonText: {
154 | height: 16,
155 | borderRadius: 4,
156 | marginBottom: 8,
157 | },
158 | });
159 |
--------------------------------------------------------------------------------
/app/dashboard/(dashboard,post,notifications,account)/notifications.tsx:
--------------------------------------------------------------------------------
1 | import { getNotifications } from "@/api/notifications";
2 | import { BodyScrollView } from "@/components/ui/BodyScrollView";
3 | import { Text } from "@/components/ui/Form";
4 | import { IconSymbol } from "@/components/ui/IconSymbol";
5 | import { useAuth } from "@/hooks/useAuth";
6 | import { useQuery } from "@tanstack/react-query";
7 | import { Pressable, StyleSheet, View } from "react-native";
8 | import { useRouter } from "expo-router";
9 |
10 | export default function Page() {
11 | const { token } = useAuth();
12 | const router = useRouter();
13 |
14 | const { data: notifications } = useQuery({
15 | queryKey: ["notifications"],
16 | queryFn: () => getNotifications(token),
17 | });
18 |
19 | return (
20 |
21 | {notifications?.length ? (
22 | notifications.map((notification) => (
23 | [
26 | styles.notificationContainer,
27 | pressed && styles.notificationPressed,
28 | ]}
29 | onPress={() => {
30 | router.back();
31 | router.push(`/dashboard/profile/${notification.fromUserId}`);
32 | }}
33 | >
34 |
35 |
44 |
45 | {notification.content}
46 |
47 | ))
48 | ) : (
49 |
50 |
51 | No notifications yet
52 | [
54 | styles.backButton,
55 | pressed && styles.backButtonPressed,
56 | ]}
57 | onPress={() => router.back()}
58 | >
59 | Back to Feed
60 |
61 |
62 | )}
63 |
64 | );
65 | }
66 |
67 | const styles = StyleSheet.create({
68 | scrollView: {
69 | flex: 1,
70 | },
71 | notificationContainer: {
72 | padding: 20,
73 | paddingRight: 24,
74 | borderBottomWidth: 1,
75 | borderBottomColor: "rgba(255, 255, 255, 0.1)",
76 | flexDirection: "row",
77 | alignItems: "center",
78 | gap: 16,
79 | backgroundColor: "rgba(255, 255, 255, 0.05)",
80 | },
81 | notificationPressed: {
82 | backgroundColor: "rgba(255, 255, 255, 0.1)",
83 | },
84 | iconContainer: {
85 | width: 48,
86 | height: 48,
87 | borderRadius: 24,
88 | backgroundColor: "rgba(108, 99, 255, 0.2)",
89 | justifyContent: "center",
90 | alignItems: "center",
91 | },
92 | notificationText: {
93 | fontSize: 16,
94 | color: "#FFFFFF",
95 | flexShrink: 1,
96 | flexWrap: "wrap",
97 | lineHeight: 22,
98 | },
99 | emptyContainer: {
100 | flex: 1,
101 | justifyContent: "center",
102 | alignItems: "center",
103 | paddingVertical: 40,
104 | gap: 16,
105 | },
106 | emptyText: {
107 | fontSize: 18,
108 | color: "#FFFFFF",
109 | opacity: 0.8,
110 | },
111 | backButton: {
112 | backgroundColor: "#6C63FF",
113 | paddingHorizontal: 20,
114 | paddingVertical: 12,
115 | borderRadius: 8,
116 | marginTop: 8,
117 | },
118 | backButtonPressed: {
119 | opacity: 0.8,
120 | },
121 | backButtonText: {
122 | color: "#FFFFFF",
123 | fontSize: 16,
124 | fontWeight: "600",
125 | },
126 | });
127 |
--------------------------------------------------------------------------------
/app/dashboard/(dashboard,post,notifications,account)/post.tsx:
--------------------------------------------------------------------------------
1 | import { createPost } from "@/api/posts";
2 | import { AppButton } from "@/components/ui/AppButton";
3 | import { Text } from "@/components/ui/Form";
4 | import { useAuth } from "@/hooks/useAuth";
5 | import { useQueryClient } from "@tanstack/react-query";
6 | import { useRouter } from "expo-router";
7 | import { useState } from "react";
8 | import { StyleSheet, TextInput, View } from "react-native";
9 |
10 | export default function Page() {
11 | const [text, setText] = useState("");
12 | const router = useRouter();
13 | const { token } = useAuth();
14 | const clientQuery = useQueryClient();
15 |
16 | function handlePost() {
17 | createPost(text, token)
18 | .then((post) => {
19 | clientQuery.invalidateQueries({ queryKey: ["posts"] });
20 | router.back();
21 | })
22 | .catch((error) => {
23 | alert(error.message);
24 | });
25 | }
26 |
27 | return (
28 |
29 |
30 | What do you want to say?
31 |
38 |
39 |
40 |
41 | Post
42 |
43 |
44 | );
45 | }
46 |
47 | const styles = StyleSheet.create({
48 | label: {
49 | fontSize: 16,
50 | textAlign: "left",
51 | },
52 | field: {
53 | gap: 12,
54 | },
55 | container: {
56 | marginTop: 66,
57 | paddingHorizontal: 24,
58 | gap: 12,
59 | },
60 | postButton: {
61 | width: "100%",
62 | },
63 | input: {
64 | backgroundColor: "#FFFFFF",
65 | padding: 12,
66 | borderRadius: 8,
67 | fontSize: 16,
68 | color: "#000000",
69 | borderWidth: 1,
70 | height: 100,
71 | borderColor: "#CCCCCC",
72 | },
73 | });
74 |
--------------------------------------------------------------------------------
/app/dashboard/(dashboard,post,notifications,account)/profile/[userId].tsx:
--------------------------------------------------------------------------------
1 | import { followUser, getFollowing } from "@/api/followers";
2 | import { getProfile } from "@/api/profiles";
3 | import { getUserPosts } from "@/api/users";
4 | import { AppButton } from "@/components/ui/AppButton";
5 | import { BodyScrollView } from "@/components/ui/BodyScrollView";
6 | import { IconSymbol } from "@/components/ui/IconSymbol";
7 | import Skeleton from "@/components/ui/Skeleton";
8 | import { Post } from "@/db/schema";
9 | import { useAuth } from "@/hooks/useAuth";
10 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
11 | import { formatDistanceToNow } from "date-fns";
12 | import { useGlobalSearchParams } from "expo-router";
13 | import React from "react";
14 | import { View, Text, StyleSheet, Image } from "react-native";
15 | import * as AC from "@bacons/apple-colors";
16 | import { getImageUrl } from "@/utils/images";
17 |
18 | export default function ProfileScreen() {
19 | const { token } = useAuth();
20 | const glob = useGlobalSearchParams();
21 | const userId = glob.userId as string;
22 |
23 | const queryClient = useQueryClient();
24 |
25 | const { data: profile, isLoading: isProfileLoading } = useQuery({
26 | queryKey: ["profile", userId],
27 | queryFn: () => getProfile(userId, token),
28 | });
29 |
30 | const { data: posts, isLoading: isPostsLoading } = useQuery({
31 | queryKey: ["posts", userId],
32 | queryFn: () => getUserPosts(userId, token),
33 | });
34 |
35 | const { data: following, isLoading: isFollowingLoading } = useQuery({
36 | queryKey: ["following", userId],
37 | queryFn: () => getFollowing(token, userId),
38 | });
39 |
40 | const { mutate: followUserMutation, isPending: isFollowLoading } =
41 | useMutation({
42 | mutationFn: ({ unfollow }: { unfollow: boolean }) =>
43 | followUser(token, userId, unfollow),
44 | onSuccess: () => {
45 | queryClient.invalidateQueries({
46 | queryKey: ["following", userId],
47 | });
48 | queryClient.invalidateQueries({
49 | queryKey: ["profile", userId],
50 | });
51 | },
52 | });
53 |
54 | return (
55 |
56 |
57 |
61 | {isProfileLoading ? (
62 |
63 | ) : (
64 | {profile?.displayName}
65 | )}
66 | {isFollowingLoading ? (
67 |
68 | ) : (
69 | {
73 | followUserMutation({ unfollow: following ? true : false });
74 | }}
75 | isLoading={isFollowLoading}
76 | icon={
77 |
86 | }
87 | >
88 | {following ? "Unfollow" : "Follow"}
89 |
90 | )}
91 |
92 |
93 |
94 | {posts?.length} Posts
95 |
96 | {profile?.followersCount} Followers
97 |
98 |
99 | {profile?.followingCount} Following
100 |
101 |
102 |
103 |
104 | {isPostsLoading ? (
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 | ) : (
113 | posts?.map((post) => )
114 | )}
115 |
116 |
117 | );
118 | }
119 |
120 | function PostComponent({ post }: { post: Post }) {
121 | return (
122 |
123 | {post.text}
124 |
125 | {formatDistanceToNow(new Date(post.createdAt), {
126 | addSuffix: true,
127 | })}
128 |
129 |
130 | );
131 | }
132 |
133 | const styles = StyleSheet.create({
134 | headerContainer: {
135 | flex: 1,
136 | justifyContent: "flex-start",
137 | alignItems: "center",
138 | gap: 18,
139 | marginBottom: 24,
140 | },
141 | postSkeleton: {
142 | width: "100%",
143 | height: 100,
144 | borderRadius: 8,
145 | },
146 | avatarImage: {
147 | width: 100,
148 | height: 100,
149 | borderRadius: 50,
150 | backgroundColor: "white",
151 | },
152 | followButton: {},
153 | unfollowButton: {
154 | backgroundColor: "#aaa",
155 | },
156 | displayNameSkeleton: {
157 | width: 220,
158 | height: 35,
159 | borderRadius: 8,
160 | },
161 | followerText: {
162 | color: "white",
163 | fontSize: 20,
164 | },
165 | followerContainer: {
166 | flex: 1,
167 | flexDirection: "row",
168 | justifyContent: "space-between",
169 | alignItems: "center",
170 | gap: 12,
171 | paddingHorizontal: 24,
172 | marginBottom: 24,
173 | },
174 | postsContainer: {
175 | gap: 12,
176 | },
177 | post: {
178 | width: "100%",
179 | backgroundColor: "#111",
180 | padding: 20,
181 | borderRadius: 8,
182 | gap: 8,
183 | },
184 | postText: {
185 | color: "white",
186 | fontSize: 22,
187 | },
188 | timestamp: {
189 | fontSize: 16,
190 | color: "gray",
191 | },
192 | text: {
193 | fontSize: 28,
194 | fontWeight: "bold",
195 | color: "white",
196 | },
197 | avatar: {
198 | width: 100,
199 | height: 100,
200 | borderRadius: 50,
201 | backgroundColor: "white",
202 | },
203 | });
204 |
--------------------------------------------------------------------------------
/app/dashboard/_layout.tsx:
--------------------------------------------------------------------------------
1 | import Tabs from "@/components/ui/Tabs";
2 |
3 | export default function Layout() {
4 | return (
5 |
6 |
14 |
15 |
23 |
24 |
32 |
33 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/assets/images/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/get-social/b2d6ef4d9382bf0d4c829c8f8dc2c79e3a4b0761/assets/images/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/get-social/b2d6ef4d9382bf0d4c829c8f8dc2c79e3a4b0761/assets/images/favicon.png
--------------------------------------------------------------------------------
/assets/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/get-social/b2d6ef4d9382bf0d4c829c8f8dc2c79e3a4b0761/assets/images/icon.png
--------------------------------------------------------------------------------
/assets/images/partial-react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/get-social/b2d6ef4d9382bf0d4c829c8f8dc2c79e3a4b0761/assets/images/partial-react-logo.png
--------------------------------------------------------------------------------
/assets/images/react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/get-social/b2d6ef4d9382bf0d4c829c8f8dc2c79e3a4b0761/assets/images/react-logo.png
--------------------------------------------------------------------------------
/assets/images/react-logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/get-social/b2d6ef4d9382bf0d4c829c8f8dc2c79e3a4b0761/assets/images/react-logo@2x.png
--------------------------------------------------------------------------------
/assets/images/react-logo@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/get-social/b2d6ef4d9382bf0d4c829c8f8dc2c79e3a4b0761/assets/images/react-logo@3x.png
--------------------------------------------------------------------------------
/assets/images/splash-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/get-social/b2d6ef4d9382bf0d4c829c8f8dc2c79e3a4b0761/assets/images/splash-icon.png
--------------------------------------------------------------------------------
/assets/images/splash.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/get-social/b2d6ef4d9382bf0d4c829c8f8dc2c79e3a4b0761/assets/images/splash.jpeg
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = (api) => {
2 | // Enable caching for faster builds
3 | api.cache(true);
4 |
5 | return {
6 | presets: ["babel-preset-expo"],
7 | plugins: [
8 | // Add class static block transform
9 | "@babel/plugin-transform-class-static-block",
10 | ].filter(Boolean),
11 | };
12 | };
13 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/get-social/b2d6ef4d9382bf0d4c829c8f8dc2c79e3a4b0761/bun.lockb
--------------------------------------------------------------------------------
/components/layout/modal.module.css:
--------------------------------------------------------------------------------
1 | .modal {
2 | display: flex;
3 | flex: 1;
4 | pointer-events: auto;
5 | background-color: var(--apple-systemGroupedBackground);
6 | border: 1px solid var(--apple-separator); /* Replace with your separator variable */
7 | }
8 |
9 | @media (min-width: 768px) {
10 | .modal {
11 | position: fixed;
12 | left: 50%;
13 | top: 50%;
14 | z-index: 50;
15 | width: 100%;
16 | max-width: 55rem;
17 | min-height: 50rem;
18 | transform: translate(-50%, -50%);
19 | box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1),
20 | 0 4px 6px -4px rgb(0 0 0 / 0.1); /* Replace with your shadow variable */
21 | max-height: 80%;
22 | overflow: scroll;
23 | border-radius: 0.5rem; /* Equivalent to sm:rounded-lg */
24 | outline: none;
25 | }
26 | }
27 |
28 | .drawerContent {
29 | position: fixed;
30 | display: flex;
31 | flex-direction: column;
32 | bottom: 0;
33 | left: 0;
34 | right: 0;
35 | border-radius: 8px 8px 0 0;
36 | overflow: hidden;
37 | height: 100%;
38 | max-height: 97%;
39 | outline: none;
40 |
41 | }
42 |
43 | @media (min-width: 768px) {
44 | .drawerContent {
45 | max-height: 100%;
46 | /* pointer-events: box-none; */
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/components/layout/modalNavigator.tsx:
--------------------------------------------------------------------------------
1 | import { Stack } from "expo-router";
2 |
3 | export default Stack;
4 |
--------------------------------------------------------------------------------
/components/layout/modalNavigator.web.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createNavigatorFactory,
3 | DefaultRouterOptions,
4 | ParamListBase,
5 | StackNavigationState,
6 | StackRouter,
7 | useNavigationBuilder,
8 | } from "@react-navigation/native";
9 | import {
10 | NativeStackNavigationOptions,
11 | NativeStackView,
12 | } from "@react-navigation/native-stack";
13 | import { withLayoutContext } from "expo-router";
14 | import React from "react";
15 | import { Platform } from "react-native";
16 | import { Drawer } from "vaul";
17 |
18 | import modalStyles from "./modal.module.css";
19 |
20 | import * as AC from "@bacons/apple-colors";
21 |
22 | /** Extend NativeStackNavigationOptions with extra sheet/detent props */
23 | type MyModalStackNavigationOptions = NativeStackNavigationOptions & {
24 | presentation?:
25 | | "modal"
26 | | "formSheet"
27 | | "containedModal"
28 | | "card"
29 | | "fullScreenModal";
30 | /**
31 | * If you want to mimic iOS sheet detents on native (iOS 16+ w/ react-native-screens),
32 | * you might do something like:
33 | *
34 | * supportedOrientations?: string[];
35 | * sheetAllowedDetents?: Array;
36 | * sheetInitialDetentIndex?: number;
37 | *
38 | * But here we specifically pass them for the web side via vaul:
39 | */
40 | sheetAllowedDetents?: (number | string)[]; // e.g. [0.5, 1.0] or ['148px', '355px', 1]
41 | sheetInitialDetentIndex?: number; // which index in `sheetAllowedDetents` is the default
42 | sheetGrabberVisible?: boolean;
43 | };
44 |
45 | type MyModalStackRouterOptions = DefaultRouterOptions & {
46 | // Extend if you need custom router logic
47 | };
48 |
49 | type Props = {
50 | initialRouteName?: string;
51 | screenOptions?: MyModalStackNavigationOptions;
52 | children: React.ReactNode;
53 | };
54 |
55 | function MyModalStackNavigator({
56 | initialRouteName,
57 | children,
58 | screenOptions,
59 | }: Props) {
60 | const { state, navigation, descriptors, NavigationContent } =
61 | useNavigationBuilder<
62 | StackNavigationState,
63 | MyModalStackRouterOptions,
64 | MyModalStackNavigationOptions
65 | >(StackRouter, {
66 | children,
67 | screenOptions,
68 | initialRouteName,
69 | });
70 |
71 | return (
72 |
73 |
78 |
79 | );
80 | }
81 | /**
82 | * Filters out "modal"/"formSheet" routes from the normal on web,
83 | * rendering them in a vaul with snap points. On native, we just let
84 | * React Navigation handle the sheet or modal transitions.
85 | */
86 | function MyModalStackView({
87 | state,
88 | navigation,
89 | descriptors,
90 | }: {
91 | state: StackNavigationState;
92 | navigation: any;
93 | descriptors: Record<
94 | string,
95 | {
96 | options: MyModalStackNavigationOptions;
97 | render: () => React.ReactNode;
98 | }
99 | >;
100 | }) {
101 | const isWeb = Platform.OS === "web";
102 |
103 | // Filter out any route that wants to be shown as a modal on web
104 | const nonModalRoutes = state.routes.filter((route) => {
105 | const descriptor = descriptors[route.key];
106 | const { presentation } = descriptor.options || {};
107 | const isModalType =
108 | presentation === "modal" ||
109 | presentation === "formSheet" ||
110 | presentation === "fullScreenModal" ||
111 | presentation === "containedModal";
112 | return !(isWeb && isModalType);
113 | });
114 |
115 | // Recalculate index so we don't point to a missing route on web
116 | let nonModalIndex = nonModalRoutes.findIndex(
117 | (r) => r.key === state.routes[state.index]?.key
118 | );
119 | if (nonModalIndex < 0) {
120 | nonModalIndex = nonModalRoutes.length - 1;
121 | }
122 |
123 | const newStackState: StackNavigationState = {
124 | ...state,
125 | routes: nonModalRoutes,
126 | index: nonModalIndex,
127 | };
128 |
129 | return (
130 |
134 | {/* Normal stack rendering for native & non-modal routes on web */}
135 |
140 |
141 | {/* Render vaul Drawer for active "modal" route on web, with snap points */}
142 | {isWeb &&
143 | state.routes.map((route, i) => {
144 | const descriptor = descriptors[route.key];
145 | const { presentation, sheetAllowedDetents, sheetGrabberVisible } =
146 | descriptor.options || {};
147 |
148 | const isModalType =
149 | presentation === "modal" ||
150 | presentation === "formSheet" ||
151 | presentation === "fullScreenModal" ||
152 | presentation === "containedModal";
153 | const isActive = i === state.index && isModalType;
154 | if (!isActive) return null;
155 |
156 | // Convert numeric detents (e.g. 0.5 => "50%") to a string
157 | // If user passes pixel or percentage strings, we'll keep them as is.
158 | const rawDetents = sheetAllowedDetents || [1];
159 |
160 | return (
161 |
{
170 | if (!open) {
171 | navigation.goBack();
172 | }
173 | }}
174 | >
175 |
176 |
183 |
187 |
188 | {/* Optional "grabber" */}
189 | {sheetGrabberVisible && (
190 |
210 | )}
211 |
212 | {/* Render the actual screen */}
213 | {descriptor.render()}
214 |
215 |
216 |
217 |
218 | );
219 | })}
220 |
221 | );
222 | }
223 |
224 | const createMyModalStack = createNavigatorFactory(MyModalStackNavigator);
225 |
226 | /**
227 | * If you're using Expo Router, wrap with `withLayoutContext`.
228 | * Otherwise, just export the createMyModalStack().Navigator as usual.
229 | */
230 | const RouterModal = withLayoutContext(createMyModalStack().Navigator);
231 |
232 | export default RouterModal;
233 |
--------------------------------------------------------------------------------
/components/runtime/local-storage.ts:
--------------------------------------------------------------------------------
1 | import { Storage } from "expo-sqlite/kv-store";
2 |
3 | // localStorage polyfill. Life's too short to not have some storage API.
4 | if (typeof localStorage === "undefined") {
5 | class StoragePolyfill {
6 | /**
7 | * Returns the number of key/value pairs.
8 | *
9 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/length)
10 | */
11 | get length(): number {
12 | return Storage.getAllKeysSync().length;
13 | }
14 | /**
15 | * Removes all key/value pairs, if there are any.
16 | *
17 | * Dispatches a storage event on Window objects holding an equivalent Storage object.
18 | *
19 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/clear)
20 | */
21 | clear(): void {
22 | Storage.clearSync();
23 | }
24 | /**
25 | * Returns the current value associated with the given key, or null if the given key does not exist.
26 | *
27 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/getItem)
28 | */
29 | getItem(key: string): string | null {
30 | return Storage.getItemSync(key) ?? null;
31 | }
32 | /**
33 | * Returns the name of the nth key, or null if n is greater than or equal to the number of key/value pairs.
34 | *
35 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/key)
36 | */
37 | key(index: number): string | null {
38 | return Storage.getAllKeysSync()[index] ?? null;
39 | }
40 | /**
41 | * Removes the key/value pair with the given key, if a key/value pair with the given key exists.
42 | *
43 | * Dispatches a storage event on Window objects holding an equivalent Storage object.
44 | *
45 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/removeItem)
46 | */
47 | removeItem(key: string): void {
48 | Storage.removeItemSync(key);
49 | }
50 | /**
51 | * Sets the value of the pair identified by key to value, creating a new key/value pair if none existed for key previously.
52 | *
53 | * Throws a "QuotaExceededError" DOMException exception if the new value couldn't be set. (Setting could fail if, e.g., the user has disabled storage for the site, or if the quota has been exceeded.)
54 | *
55 | * Dispatches a storage event on Window objects holding an equivalent Storage object.
56 | *
57 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/setItem)
58 | */
59 | setItem(key: string, value: string): void {
60 | Storage.setItemSync(key, value);
61 | }
62 | // [name: string]: any;
63 | }
64 |
65 | const localStoragePolyfill = new StoragePolyfill();
66 |
67 | Object.defineProperty(global, "localStorage", {
68 | value: localStoragePolyfill,
69 | });
70 | }
71 |
--------------------------------------------------------------------------------
/components/runtime/local-storage.web.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/get-social/b2d6ef4d9382bf0d4c829c8f8dc2c79e3a4b0761/components/runtime/local-storage.web.ts
--------------------------------------------------------------------------------
/components/ui/AppButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | Text,
4 | Pressable,
5 | StyleSheet,
6 | StyleProp,
7 | ViewStyle,
8 | ActivityIndicator,
9 | View,
10 | } from "react-native";
11 |
12 | export function AppButton({
13 | children,
14 | onPress,
15 | style,
16 | disabled,
17 | isLoading,
18 | icon,
19 | }: {
20 | children: React.ReactNode;
21 | onPress: () => void;
22 | style?: StyleProp;
23 | disabled?: boolean;
24 | isLoading?: boolean;
25 | icon?: React.ReactNode;
26 | }) {
27 | return (
28 |
33 | {isLoading ? (
34 |
35 | ) : (
36 |
37 | {children}
38 | {icon}
39 |
40 | )}
41 |
42 | );
43 | }
44 |
45 | const styles = StyleSheet.create({
46 | button: {
47 | padding: 12,
48 | borderRadius: 8,
49 | width: 140,
50 | backgroundColor: "#4A90E2",
51 | gap: 2,
52 | },
53 | buttonContent: {
54 | gap: 4,
55 | flexDirection: "row",
56 | alignItems: "center",
57 | justifyContent: "center",
58 | },
59 | disabled: {
60 | backgroundColor: "#aaa",
61 | },
62 | buttonText: {
63 | color: "white",
64 | fontSize: 16,
65 | fontWeight: "bold",
66 | textAlign: "center",
67 | },
68 | });
69 |
--------------------------------------------------------------------------------
/components/ui/BodyScrollView.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import useMergedRef from "@/hooks/useMergedRef";
4 | import { useScrollToTop } from "@/hooks/useTabToTop";
5 | import * as AC from "@bacons/apple-colors";
6 | import { forwardRef, useRef } from "react";
7 | import { ScrollViewProps } from "react-native";
8 | import Animated from "react-native-reanimated";
9 | import { useSafeAreaInsets } from "react-native-safe-area-context";
10 | import { useBottomTabOverflow } from "./TabBarBackground";
11 |
12 | export const BodyScrollView = forwardRef((props, ref) => {
13 | const paddingBottom = useBottomTabOverflow();
14 | const scrollRef = useRef(null);
15 |
16 | const statusBarInset = useSafeAreaInsets().top; // inset of the status bar
17 |
18 | const largeHeaderInset = statusBarInset + 92; // inset to use for a large header since it's frame is equal to 96 + the frame of status bar
19 |
20 | useScrollToTop(scrollRef, -largeHeaderInset);
21 |
22 | const merged = useMergedRef(scrollRef, ref);
23 |
24 | return (
25 |
35 | );
36 | });
37 |
38 | if (__DEV__) {
39 | BodyScrollView.displayName = "BodyScrollView";
40 | }
41 |
--------------------------------------------------------------------------------
/components/ui/ContentUnavailable.tsx:
--------------------------------------------------------------------------------
1 | // Similar to https://developer.apple.com/documentation/swiftui/contentunavailableview
2 |
3 | import { View, Text } from "react-native";
4 | import { IconSymbol, IconSymbolName } from "./IconSymbol";
5 | import * as AC from "@bacons/apple-colors";
6 |
7 | type Props = {
8 | title: string;
9 | description?: string;
10 | systemImage: IconSymbolName | (React.ReactElement & {});
11 | actions?: React.ReactNode;
12 | };
13 |
14 | export function ContentUnavailable({
15 | title,
16 | description,
17 | systemImage,
18 | actions,
19 | ...props
20 | }:
21 | | Props
22 | | ({
23 | search: boolean | string;
24 | } & Partial)
25 | | ({
26 | internet: boolean;
27 | } & Partial)) {
28 | let resolvedTitle = title;
29 | let resolvedSystemImage = systemImage;
30 | let resolvedDescription = description;
31 | let animationSpec:
32 | | import("expo-symbols").SymbolViewProps["animationSpec"]
33 | | undefined;
34 |
35 | if ("search" in props && props.search) {
36 | resolvedTitle =
37 | title ?? typeof props.search === "string"
38 | ? `No Results for "${props.search}"`
39 | : `No Results`;
40 | resolvedSystemImage ??= "magnifyingglass";
41 | resolvedDescription ??= `Check the spelling or try a new search.`;
42 | } else if ("internet" in props && props.internet) {
43 | resolvedTitle ??= "Connection issue";
44 | resolvedSystemImage ??=
45 | process.env.EXPO_OS === "ios" ? "wifi" : "wifi.slash";
46 |
47 | animationSpec = {
48 | repeating: true,
49 | variableAnimationSpec: {
50 | iterative: true,
51 | dimInactiveLayers: true,
52 | },
53 | };
54 | resolvedDescription ??= `Check your internet connection.`;
55 | }
56 |
57 | return (
58 |
66 | {typeof resolvedSystemImage === "string" ? (
67 |
73 | ) : (
74 | resolvedSystemImage
75 | )}
76 |
77 |
84 |
93 | {resolvedTitle}
94 |
95 | {resolvedDescription && (
96 |
104 | {resolvedDescription}
105 |
106 | )}
107 |
108 | {actions}
109 |
110 | );
111 | }
112 |
--------------------------------------------------------------------------------
/components/ui/FadeIn.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Animated, { FadeIn as EnterFadeIn } from "react-native-reanimated";
4 |
5 | export function FadeIn({ children }: { children: React.ReactNode }) {
6 | return (
7 |
8 | {children}
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/components/ui/Form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { IconSymbol, IconSymbolName } from "@/components/ui/IconSymbol";
4 | import * as AppleColors from "@bacons/apple-colors";
5 | import { Href, LinkProps, Link as RouterLink, Stack } from "expo-router";
6 | import * as WebBrowser from "expo-web-browser";
7 | import React, { forwardRef } from "react";
8 | import {
9 | Button,
10 | OpaqueColorValue,
11 | Text as RNText,
12 | ScrollViewProps,
13 | StyleProp,
14 | StyleSheet,
15 | TextProps,
16 | TextStyle,
17 | TouchableHighlight,
18 | View,
19 | ViewProps,
20 | ViewStyle,
21 | } from "react-native";
22 | import { BodyScrollView } from "./BodyScrollView";
23 | import { HeaderButton } from "./Header";
24 |
25 | type ListStyle = "grouped" | "auto";
26 |
27 | const ListStyleContext = React.createContext("auto");
28 |
29 | export const List = forwardRef<
30 | any,
31 | ScrollViewProps & {
32 | /** Set the Expo Router ` ` title when mounted. */
33 | navigationTitle?: string;
34 | listStyle?: ListStyle;
35 | }
36 | >(({ contentContainerStyle, ...props }, ref) => {
37 | return (
38 | <>
39 | {props.navigationTitle && (
40 |
41 | )}
42 |
43 |
59 |
60 | >
61 | );
62 | });
63 |
64 | if (__DEV__) List.displayName = "FormList";
65 |
66 | export function HStack(props: ViewProps) {
67 | return (
68 |
82 | );
83 | }
84 |
85 | const minItemHeight = 20;
86 |
87 | const styles = StyleSheet.create({
88 | itemPadding: {
89 | paddingVertical: 11,
90 | paddingHorizontal: 20,
91 | },
92 | });
93 |
94 | export const FormItem = forwardRef<
95 | typeof TouchableHighlight,
96 | Pick & { href?: Href; onPress?: () => void }
97 | >(function FormItem({ children, href, onPress }, ref) {
98 | if (href == null) {
99 | if (onPress == null) {
100 | return (
101 |
102 | {children}
103 |
104 | );
105 | }
106 | return (
107 |
112 |
113 | {children}
114 |
115 |
116 | );
117 | }
118 |
119 | return (
120 |
121 |
122 |
123 | {children}
124 |
125 |
126 |
127 | );
128 | });
129 |
130 | const Colors = {
131 | systemGray4: AppleColors.systemGray4, // "rgba(209, 209, 214, 1)",
132 | secondarySystemGroupedBackground:
133 | AppleColors.secondarySystemGroupedBackground, // "rgba(255, 255, 255, 1)",
134 | separator: AppleColors.separator, // "rgba(61.2, 61.2, 66, 0.29)",
135 | };
136 |
137 | type SystemImageProps =
138 | | IconSymbolName
139 | | {
140 | name: IconSymbolName;
141 | color?: OpaqueColorValue;
142 | size?: number;
143 | };
144 |
145 | /** Text but with iOS default color and sizes. */
146 | export const Text = React.forwardRef<
147 | RNText,
148 | TextProps & {
149 | /** Value displayed on the right side of the form item. */
150 | hint?: React.ReactNode;
151 | /** Adds a prefix SF Symbol image to the left of the text */
152 | systemImage?: SystemImageProps;
153 |
154 | bold?: boolean;
155 | }
156 | >(({ bold, ...props }, ref) => {
157 | const font: TextStyle = {
158 | ...FormFont.default,
159 | flexShrink: 0,
160 | fontWeight: bold ? "600" : "normal",
161 | };
162 |
163 | return (
164 |
170 | );
171 | });
172 |
173 | if (__DEV__) Text.displayName = "FormText";
174 |
175 | export const Link = React.forwardRef<
176 | typeof RouterLink,
177 | LinkProps & {
178 | /** Value displayed on the right side of the form item. */
179 | hint?: React.ReactNode;
180 | /** Adds a prefix SF Symbol image to the left of the text. */
181 | systemImage?: SystemImageProps;
182 |
183 | /** Changes the right icon. */
184 | hintImage?: SystemImageProps;
185 |
186 | // TODO: Automatically detect this somehow.
187 | /** Is the link inside a header. */
188 | headerRight?: boolean;
189 |
190 | bold?: boolean;
191 | }
192 | >(({ bold, children, headerRight, hintImage, ...props }, ref) => {
193 | const font: TextStyle = {
194 | ...FormFont.default,
195 | fontWeight: bold ? "600" : "normal",
196 | };
197 |
198 | const resolvedChildren = (() => {
199 | if (headerRight) {
200 | if (process.env.EXPO_OS === "web") {
201 | return {children}
;
202 | }
203 | const wrappedTextChildren = React.Children.map(children, (child) => {
204 | // Filter out empty children
205 | if (!child) {
206 | return null;
207 | }
208 | if (typeof child === "string") {
209 | return (
210 | (
212 | { ...font, color: AppleColors.link },
213 | props.style
214 | )}
215 | >
216 | {child}
217 |
218 | );
219 | }
220 | return child;
221 | });
222 |
223 | return (
224 |
232 | {wrappedTextChildren}
233 |
234 | );
235 | }
236 | return children;
237 | })();
238 |
239 | return (
240 | (font, props.style)}
248 | onPress={
249 | process.env.EXPO_OS === "web"
250 | ? props.onPress
251 | : (e) => {
252 | if (
253 | props.target === "_blank" &&
254 | // Ensure the resolved href is an external URL.
255 | /^([\w\d_+.-]+:)?\/\//.test(RouterLink.resolveHref(props.href))
256 | ) {
257 | // Prevent the default behavior of linking to the default browser on native.
258 | e.preventDefault();
259 | // Open the link in an in-app browser.
260 | WebBrowser.openBrowserAsync(props.href as string, {
261 | presentationStyle:
262 | WebBrowser.WebBrowserPresentationStyle.AUTOMATIC,
263 | });
264 | } else {
265 | props.onPress?.(e);
266 | }
267 | }
268 | }
269 | children={resolvedChildren}
270 | />
271 | );
272 | });
273 |
274 | if (__DEV__) Link.displayName = "FormLink";
275 |
276 | export const FormFont = {
277 | // From inspecting SwiftUI `List { Text("Foo") }` in Xcode.
278 | default: {
279 | color: AppleColors.label,
280 | // 17.00pt is the default font size for a Text in a List.
281 | fontSize: 17,
282 | // UICTFontTextStyleBody is the default fontFamily.
283 | },
284 | secondary: {
285 | color: AppleColors.secondaryLabel,
286 | fontSize: 17,
287 | },
288 | caption: {
289 | color: AppleColors.secondaryLabel,
290 | fontSize: 12,
291 | },
292 | title: {
293 | color: AppleColors.label,
294 | fontSize: 17,
295 | fontWeight: "600",
296 | },
297 | };
298 |
299 | export function Section({
300 | children,
301 | title,
302 | footer,
303 | ...props
304 | }: ViewProps & {
305 | title?: string | React.ReactNode;
306 | footer?: string | React.ReactNode;
307 | }) {
308 | const listStyle = React.useContext(ListStyleContext) ?? "auto";
309 |
310 | const childrenWithSeparator = React.Children.map(children, (child, index) => {
311 | if (!React.isValidElement(child)) {
312 | return child;
313 | }
314 | const isLastChild = index === React.Children.count(children) - 1;
315 |
316 | const resolvedProps = {
317 | ...child.props,
318 | };
319 | // Extract onPress from child
320 | const originalOnPress = resolvedProps.onPress;
321 | let wrapsFormItem = false;
322 | if (child.type === Button) {
323 | const { title, color } = resolvedProps;
324 |
325 | delete resolvedProps.title;
326 | resolvedProps.style = mergedStyleProp(
327 | { color: color ?? AppleColors.link },
328 | resolvedProps.style
329 | );
330 | child = {title} ;
331 | }
332 |
333 | if (
334 | // If child is type of Text, add default props
335 | child.type === RNText ||
336 | child.type === Text
337 | ) {
338 | child = React.cloneElement(child, {
339 | dynamicTypeRamp: "body",
340 | numberOfLines: 1,
341 | adjustsFontSizeToFit: true,
342 | ...resolvedProps,
343 | onPress: undefined,
344 | style: mergedStyleProp(FormFont.default, resolvedProps.style),
345 | });
346 |
347 | const hintView = (() => {
348 | if (!resolvedProps.hint) {
349 | return null;
350 | }
351 |
352 | return React.Children.map(resolvedProps.hint, (child) => {
353 | // Filter out empty children
354 | if (!child) {
355 | return null;
356 | }
357 | if (typeof child === "string") {
358 | return (
359 |
367 | {child}
368 |
369 | );
370 | }
371 | return child;
372 | });
373 | })();
374 |
375 | const symbolView = (() => {
376 | if (!resolvedProps.systemImage) {
377 | return null;
378 | }
379 |
380 | const symbolProps =
381 | typeof resolvedProps.systemImage === "string"
382 | ? { name: resolvedProps.systemImage }
383 | : resolvedProps.systemImage;
384 |
385 | return (
386 |
396 | );
397 | })();
398 |
399 | if (hintView || symbolView) {
400 | child = (
401 |
402 | {symbolView}
403 | {child}
404 | {hintView && }
405 | {hintView}
406 |
407 | );
408 | }
409 | } else if (child.type === RouterLink || child.type === Link) {
410 | wrapsFormItem = true;
411 |
412 | const wrappedTextChildren = React.Children.map(
413 | resolvedProps.children,
414 | (linkChild) => {
415 | // Filter out empty children
416 | if (!linkChild) {
417 | return null;
418 | }
419 | if (typeof linkChild === "string") {
420 | return (
421 |
425 | {linkChild}
426 |
427 | );
428 | }
429 | return linkChild;
430 | }
431 | );
432 |
433 | const hintView = (() => {
434 | if (!resolvedProps.hint) {
435 | return null;
436 | }
437 |
438 | return React.Children.map(resolvedProps.hint, (child) => {
439 | // Filter out empty children
440 | if (!child) {
441 | return null;
442 | }
443 | if (typeof child === "string") {
444 | return {child} ;
445 | }
446 | return child;
447 | });
448 | })();
449 |
450 | const symbolView = (() => {
451 | if (!resolvedProps.systemImage) {
452 | return null;
453 | }
454 | const symbolProps =
455 | typeof resolvedProps.systemImage === "string"
456 | ? { name: resolvedProps.systemImage }
457 | : resolvedProps.systemImage;
458 |
459 | return (
460 |
470 | );
471 | })();
472 |
473 | child = React.cloneElement(child, {
474 | style: [
475 | FormFont.default,
476 | process.env.EXPO_OS === "web" && {
477 | alignItems: "stretch",
478 | flexDirection: "column",
479 | display: "flex",
480 | },
481 | resolvedProps.style,
482 | ],
483 | dynamicTypeRamp: "body",
484 | numberOfLines: 1,
485 | adjustsFontSizeToFit: true,
486 | asChild: process.env.EXPO_OS !== "web",
487 | children: (
488 |
489 |
490 | {symbolView}
491 | {wrappedTextChildren}
492 |
493 | {hintView}
494 |
495 |
499 |
500 |
501 |
502 | ),
503 | });
504 | }
505 | // Ensure child is a FormItem otherwise wrap it in a FormItem
506 | if (!wrapsFormItem && !child.props.custom && child.type !== FormItem) {
507 | child = {child} ;
508 | }
509 |
510 | return (
511 | <>
512 | {child}
513 | {!isLastChild && }
514 | >
515 | );
516 | });
517 |
518 | const contents = (
519 |
539 | );
540 |
541 | const padding = listStyle === "grouped" ? 0 : 16;
542 |
543 | if (!title && !footer) {
544 | return (
545 |
550 | {contents}
551 |
552 | );
553 | }
554 |
555 | return (
556 |
561 | {title && (
562 |
574 | {title}
575 |
576 | )}
577 | {contents}
578 | {footer && (
579 |
588 | {footer}
589 |
590 | )}
591 |
592 | );
593 | }
594 |
595 | function LinkChevronIcon({
596 | href,
597 | systemImage,
598 | }: {
599 | href?: any;
600 | systemImage?: SystemImageProps;
601 | }) {
602 | const isHrefExternal =
603 | typeof href === "string" && /^([\w\d_+.-]+:)?\/\//.test(href);
604 |
605 | const size = process.env.EXPO_OS === "ios" ? 14 : 24;
606 |
607 | if (systemImage && typeof systemImage !== "string") {
608 | return (
609 |
614 | );
615 | }
616 |
617 | const resolvedName =
618 | typeof systemImage === "string"
619 | ? systemImage
620 | : isHrefExternal
621 | ? "arrow.up.right"
622 | : "chevron.right";
623 |
624 | return (
625 |
634 | );
635 | }
636 |
637 | function Separator() {
638 | return (
639 |
647 | );
648 | }
649 |
650 | function mergedStyles(style: ViewStyle | TextStyle, props: any) {
651 | return mergedStyleProp(style, props.style);
652 | }
653 |
654 | export function mergedStyleProp(
655 | style: TStyle,
656 | styleProps?: StyleProp | null
657 | ): StyleProp {
658 | if (styleProps == null) {
659 | return style;
660 | } else if (Array.isArray(styleProps)) {
661 | return [style, ...styleProps];
662 | }
663 | return [style, styleProps];
664 | }
665 |
666 | function extractStyle(styleProp: any, key: string) {
667 | if (styleProp == null) {
668 | return undefined;
669 | } else if (Array.isArray(styleProp)) {
670 | return styleProp.find((style) => {
671 | return style[key] != null;
672 | })?.[key];
673 | } else if (typeof styleProp === "object") {
674 | return styleProp?.[key];
675 | }
676 | return null;
677 | }
678 |
--------------------------------------------------------------------------------
/components/ui/Header.tsx:
--------------------------------------------------------------------------------
1 | // Fork of upstream but with forward ref for Link asChild
2 | // https://github.com/react-navigation/react-navigation/blob/bddcc44ab0e0ad5630f7ee0feb69496412a00217/packages/elements/src/Header/HeaderButton.tsx#L1
3 | import {
4 | PlatformPressable,
5 | type HeaderButtonProps,
6 | } from "@react-navigation/elements";
7 | import React from "react";
8 | import { Platform, StyleSheet } from "react-native";
9 |
10 | export const HeaderButton = React.forwardRef(function HeaderButton(
11 | {
12 | disabled,
13 | onPress,
14 | pressColor,
15 | pressOpacity,
16 | accessibilityLabel,
17 | testID,
18 | style,
19 | href,
20 | children,
21 | }: HeaderButtonProps,
22 | ref: React.Ref
23 | ) {
24 | return (
25 |
40 | {children}
41 |
42 | );
43 | });
44 |
45 | const androidRipple = {
46 | borderless: true,
47 | foreground: Platform.OS === "android" && Platform.Version >= 23,
48 | radius: 20,
49 | };
50 |
51 | const styles = StyleSheet.create({
52 | container: {
53 | flexDirection: "row",
54 | alignItems: "center",
55 | paddingHorizontal: 8,
56 | // Roundness for iPad hover effect
57 | borderRadius: 10,
58 | },
59 | disabled: {
60 | opacity: 0.5,
61 | },
62 | });
63 |
--------------------------------------------------------------------------------
/components/ui/IconSymbol.ios.tsx:
--------------------------------------------------------------------------------
1 | import { SymbolView, SymbolViewProps, SymbolWeight } from "expo-symbols";
2 | import { StyleProp, ViewStyle } from "react-native";
3 |
4 | export function IconSymbol({
5 | name,
6 | size = 24,
7 | color,
8 | style,
9 | weight = "regular",
10 | animationSpec,
11 | }: {
12 | name: SymbolViewProps["name"];
13 | size?: number;
14 | color: string;
15 | style?: StyleProp;
16 | weight?: SymbolWeight;
17 | animationSpec?: SymbolViewProps["animationSpec"];
18 | }) {
19 | return (
20 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/components/ui/IconSymbol.tsx:
--------------------------------------------------------------------------------
1 | import { IconSymbolMaterial, IconSymbolName } from "./IconSymbolFallback";
2 |
3 | export const IconSymbol = IconSymbolMaterial;
4 |
5 | export { IconSymbolName };
6 |
--------------------------------------------------------------------------------
/components/ui/Segments.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, {
4 | createContext,
5 | useContext,
6 | useState,
7 | ReactNode,
8 | Children,
9 | } from "react";
10 | import SegmentedControl from "@react-native-segmented-control/segmented-control";
11 | import { StyleProp, View, ViewStyle } from "react-native";
12 |
13 | /* ----------------------------------------------------------------------------------
14 | * Context
15 | * ----------------------------------------------------------------------------------*/
16 |
17 | interface SegmentsContextValue {
18 | value: string;
19 | setValue: React.Dispatch>;
20 | }
21 |
22 | const SegmentsContext = createContext(
23 | undefined
24 | );
25 |
26 | /* ----------------------------------------------------------------------------------
27 | * Segments (Container)
28 | * ----------------------------------------------------------------------------------*/
29 |
30 | interface SegmentsProps {
31 | /** The initial value for the controlled Segments */
32 | defaultValue: string;
33 |
34 | /** The children of the Segments component (SegmentsList, SegmentsContent, etc.) */
35 | children: ReactNode;
36 | }
37 |
38 | export function Segments({ defaultValue, children }: SegmentsProps) {
39 | const [value, setValue] = useState(defaultValue);
40 |
41 | return (
42 |
43 | {children}
44 |
45 | );
46 | }
47 |
48 | Segments.displayName = "Segments";
49 |
50 | export function SegmentsList({
51 | children,
52 | style,
53 | }: {
54 | /** The children will typically be one or more SegmentsTrigger elements */
55 | children: ReactNode;
56 | style?: StyleProp;
57 | }) {
58 | const context = useContext(SegmentsContext);
59 | if (!context) {
60 | throw new Error("SegmentsList must be used within a Segments");
61 | }
62 |
63 | const { value, setValue } = context;
64 |
65 | // Filter out only SegmentsTrigger elements
66 | const triggers = Children.toArray(children).filter(
67 | (child: any) => child.type?.displayName === "SegmentsTrigger"
68 | );
69 |
70 | // Collect labels and values from each SegmentsTrigger
71 | const labels = triggers.map((trigger: any) => trigger.props.children);
72 | const values = triggers.map((trigger: any) => trigger.props.value);
73 |
74 | // When the user switches the segment, update the context value
75 | const handleChange = (event: any) => {
76 | const index = event.nativeEvent.selectedSegmentIndex;
77 | setValue(values[index]);
78 | };
79 |
80 | return (
81 |
87 | );
88 | }
89 | SegmentsList.displayName = "SegmentsList";
90 |
91 | /* ----------------------------------------------------------------------------------
92 | * SegmentsTrigger
93 | * ----------------------------------------------------------------------------------*/
94 |
95 | interface SegmentsTriggerProps {
96 | /** The value that this trigger represents */
97 | value: string;
98 | /** The label to display for this trigger in the SegmentedControl */
99 | children: ReactNode;
100 | }
101 |
102 | export function SegmentsTrigger({ value, children }: SegmentsTriggerProps) {
103 | // We don't actually render anything here. This component serves as a "marker"
104 | // for the SegmentsList to know about possible segments.
105 | return null;
106 | }
107 |
108 | SegmentsTrigger.displayName = "SegmentsTrigger";
109 |
110 | /* ----------------------------------------------------------------------------------
111 | * SegmentsContent
112 | * ----------------------------------------------------------------------------------*/
113 |
114 | interface SegmentsContentProps {
115 | /** The value from the matching SegmentsTrigger */
116 | value: string;
117 | /** The content to be rendered when the active value matches */
118 | children: ReactNode;
119 | }
120 |
121 | export function SegmentsContent({ value, children }: SegmentsContentProps) {
122 | const context = useContext(SegmentsContext);
123 | if (!context) {
124 | throw new Error("SegmentsContent must be used within a Segments");
125 | }
126 |
127 | const { value: currentValue } = context;
128 | if (currentValue !== value) {
129 | return null;
130 | }
131 |
132 | if (process.env.EXPO_OS === "web") {
133 | return {children}
;
134 | }
135 |
136 | return {children} ;
137 | }
138 |
139 | Segments.List = SegmentsList;
140 | Segments.Trigger = SegmentsTrigger;
141 | Segments.Content = SegmentsContent;
142 |
--------------------------------------------------------------------------------
/components/ui/Skeleton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 | import {
5 | Animated,
6 | Easing,
7 | StyleProp,
8 | StyleSheet,
9 | View,
10 | ViewStyle,
11 | useColorScheme,
12 | } from "react-native";
13 |
14 | const BASE_COLORS = {
15 | dark: { primary: "rgb(17, 17, 17)", secondary: "rgb(51, 51, 51)" },
16 | light: {
17 | primary: "rgb(250, 250, 250)",
18 | secondary: "rgb(205, 205, 205)",
19 | },
20 | } as const;
21 |
22 | const makeColors = (mode: keyof typeof BASE_COLORS) => [
23 | BASE_COLORS[mode].primary,
24 | BASE_COLORS[mode].secondary,
25 | BASE_COLORS[mode].secondary,
26 | BASE_COLORS[mode].primary,
27 | BASE_COLORS[mode].secondary,
28 | BASE_COLORS[mode].primary,
29 | ];
30 |
31 | const DARK_COLORS = new Array(3)
32 | .fill(0)
33 | .map(() => makeColors("dark"))
34 | .flat();
35 |
36 | const LIGHT_COLORS = new Array(3)
37 | .fill(0)
38 | .map(() => makeColors("light"))
39 | .flat();
40 |
41 | export const SkeletonBox = ({
42 | width,
43 | height,
44 | borderRadius = 8,
45 | delay,
46 | }: {
47 | width: number;
48 | height: number;
49 | borderRadius?: number;
50 | delay?: number;
51 | }) => {
52 | return (
53 |
65 | );
66 | };
67 |
68 | const Skeleton = ({
69 | style,
70 | delay,
71 | dark: inputDark,
72 | }: {
73 | style?: StyleProp;
74 | delay?: number;
75 | dark?: boolean;
76 | } = {}) => {
77 | // eslint-disable-next-line react-hooks/rules-of-hooks
78 | const dark = inputDark ?? useColorScheme() !== "light";
79 | const translateX = React.useRef(new Animated.Value(-1)).current;
80 | const [width, setWidth] = React.useState(150);
81 |
82 | const colors = dark ? DARK_COLORS : LIGHT_COLORS;
83 | const targetRef = React.useRef(null);
84 |
85 | const onLayout = React.useCallback(() => {
86 | targetRef.current?.measureInWindow((_x, _y, width, _height) => {
87 | setWidth(width);
88 | });
89 | }, []);
90 |
91 | React.useEffect(() => {
92 | const anim = Animated.loop(
93 | Animated.sequence([
94 | Animated.timing(translateX, {
95 | delay: delay || 0,
96 | toValue: 1,
97 | duration: 5000,
98 | useNativeDriver: process.env.EXPO_OS !== "web",
99 | // Ease in
100 | easing: Easing.in(Easing.ease),
101 | }),
102 | ])
103 | );
104 | anim.start();
105 | return () => {
106 | anim.stop();
107 | };
108 | }, [translateX, delay]);
109 |
110 | const translateXStyle = React.useMemo(
111 | () => ({
112 | transform: [
113 | {
114 | translateX: translateX.interpolate({
115 | inputRange: [-1, 1],
116 | outputRange: [-width * 8, width],
117 | }),
118 | },
119 | ],
120 | }),
121 | [translateX, width]
122 | );
123 |
124 | return (
125 |
139 |
145 |
157 |
158 |
159 | );
160 | };
161 |
162 | export default Skeleton;
163 |
--------------------------------------------------------------------------------
/components/ui/Skeleton.web.tsx:
--------------------------------------------------------------------------------
1 | // Use CSS to prevent blocking the suspense loading state with a skeleton loader.
2 | import React from "react";
3 | import { View } from "react-native";
4 |
5 | import * as AC from "@bacons/apple-colors";
6 |
7 | export const SkeletonBox = ({
8 | width,
9 | height,
10 | borderRadius = 8,
11 | delay,
12 | }: {
13 | width: number;
14 | height: number;
15 | borderRadius?: number;
16 | delay?: number;
17 | }) => {
18 | return (
19 |
31 | );
32 | };
33 |
34 | const Skeleton = ({
35 | style,
36 | delay,
37 | dark: inputDark,
38 | }: {
39 | style?: any;
40 | delay?: number;
41 | dark?: boolean;
42 | } = {}) => {
43 | const dark =
44 | inputDark != null
45 | ? {
46 | bg: inputDark ? "#111111" : "#e0e0e0",
47 | low: inputDark ? "rgba(255,255,255,0.1)" : "rgba(255,255,255,0.4)",
48 | }
49 | : {
50 | bg: AC.secondarySystemBackground,
51 | low: AC.tertiaryLabel,
52 | };
53 |
54 | return (
55 |
65 |
76 |
88 |
89 | );
90 | };
91 |
92 | export default Skeleton;
93 |
--------------------------------------------------------------------------------
/components/ui/Stack.tsx:
--------------------------------------------------------------------------------
1 | // import { Stack as NativeStack } from "expo-router";
2 | import { NativeStackNavigationOptions } from "@react-navigation/native-stack";
3 | import React from "react";
4 |
5 | // Better transitions on web, no changes on native.
6 | import NativeStack from "@/components/layout/modalNavigator";
7 |
8 | // These are the default stack options for iOS, they disable on other platforms.
9 | const DEFAULT_STACK_HEADER: NativeStackNavigationOptions =
10 | process.env.EXPO_OS !== "ios"
11 | ? {}
12 | : {
13 | headerTransparent: true,
14 | headerBlurEffect: "systemChromeMaterial",
15 | headerShadowVisible: true,
16 | headerLargeTitleShadowVisible: false,
17 | headerLargeStyle: {
18 | backgroundColor: "transparent",
19 | },
20 | headerLargeTitle: true,
21 | };
22 |
23 | /** Create a bottom sheet on iOS with extra snap points (`sheetAllowedDetents`) */
24 | export const BOTTOM_SHEET: NativeStackNavigationOptions = {
25 | // https://github.com/software-mansion/react-native-screens/blob/main/native-stack/README.md#sheetalloweddetents
26 | presentation: "formSheet",
27 | gestureDirection: "vertical",
28 | animation: "slide_from_bottom",
29 | sheetGrabberVisible: true,
30 | sheetInitialDetentIndex: 0,
31 | sheetAllowedDetents: [0.5, 1.0],
32 | };
33 |
34 | export default function Stack({
35 | screenOptions,
36 | children,
37 | ...props
38 | }: React.ComponentProps) {
39 | const processedChildren = React.Children.map(children, (child) => {
40 | if (React.isValidElement(child)) {
41 | const { sheet, modal, ...props } = child.props;
42 | if (sheet) {
43 | return React.cloneElement(child, {
44 | ...props,
45 | options: {
46 | ...BOTTOM_SHEET,
47 | ...props.options,
48 | },
49 | });
50 | } else if (modal) {
51 | return React.cloneElement(child, {
52 | ...props,
53 | options: {
54 | presentation: "modal",
55 | ...props.options,
56 | },
57 | });
58 | }
59 | }
60 | return child;
61 | });
62 |
63 | return (
64 |
72 | );
73 | }
74 |
75 | Stack.Screen = NativeStack.Screen as React.FC<
76 | React.ComponentProps & {
77 | /** Make the sheet open as a bottom sheet with default options on iOS. */
78 | sheet?: boolean;
79 | /** Make the screen open as a modal. */
80 | modal?: boolean;
81 | }
82 | >;
83 |
--------------------------------------------------------------------------------
/components/ui/TabBarBackground.ios.tsx:
--------------------------------------------------------------------------------
1 | import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
2 | import { BlurView } from "expo-blur";
3 | import { StyleSheet } from "react-native";
4 | import { useSafeAreaInsets } from "react-native-safe-area-context";
5 |
6 | export default function BlurTabBarBackground() {
7 | return (
8 |
15 | );
16 | }
17 |
18 | export function useBottomTabOverflow() {
19 | let tabHeight = 0;
20 | try {
21 | // eslint-disable-next-line react-hooks/rules-of-hooks
22 | tabHeight = useBottomTabBarHeight();
23 | } catch {}
24 | const { bottom } = useSafeAreaInsets();
25 | return tabHeight - bottom;
26 | }
27 |
--------------------------------------------------------------------------------
/components/ui/TabBarBackground.tsx:
--------------------------------------------------------------------------------
1 | // This is a shim for web and Android where the tab bar is generally opaque.
2 | export default undefined;
3 |
4 | export function useBottomTabOverflow() {
5 | return 0;
6 | }
7 |
--------------------------------------------------------------------------------
/components/ui/Tabs.tsx:
--------------------------------------------------------------------------------
1 | import { IconSymbol, IconSymbolName } from "@/components/ui/IconSymbol";
2 | import {
3 | BottomTabBarButtonProps,
4 | BottomTabNavigationOptions,
5 | } from "@react-navigation/bottom-tabs";
6 | import * as Haptics from "expo-haptics";
7 | import React from "react";
8 | // Better transitions on web, no changes on native.
9 | import { PlatformPressable } from "@react-navigation/elements";
10 | import { Tabs as NativeTabs } from "expo-router";
11 | import BlurTabBarBackground from "./TabBarBackground";
12 |
13 | // These are the default tab options for iOS, they disable on other platforms.
14 | const DEFAULT_TABS: BottomTabNavigationOptions =
15 | process.env.EXPO_OS !== "ios"
16 | ? {
17 | headerShown: false,
18 | }
19 | : {
20 | headerShown: false,
21 | tabBarButton: HapticTab,
22 | tabBarBackground: BlurTabBarBackground,
23 | tabBarStyle: {
24 | // Use a transparent background on iOS to show the blur effect
25 | position: "absolute",
26 | },
27 | };
28 |
29 | export default function Tabs({
30 | screenOptions,
31 | children,
32 | ...props
33 | }: React.ComponentProps) {
34 | const processedChildren = React.Children.map(children, (child) => {
35 | if (React.isValidElement(child)) {
36 | const { systemImage, title, ...props } = child.props;
37 | if (systemImage || title != null) {
38 | return React.cloneElement(child, {
39 | ...props,
40 | options: {
41 | tabBarIcon: !systemImage
42 | ? undefined
43 | : (props: any) => ,
44 | title,
45 | ...props.options,
46 | },
47 | });
48 | }
49 | }
50 | return child;
51 | });
52 |
53 | return (
54 |
62 | );
63 | }
64 |
65 | Tabs.Screen = NativeTabs.Screen as React.FC<
66 | React.ComponentProps & {
67 | /** Add a system image for the tab icon. */
68 | systemImage?: IconSymbolName;
69 | /** Set the title of the icon. */
70 | title?: string;
71 | }
72 | >;
73 |
74 | function HapticTab(props: BottomTabBarButtonProps) {
75 | return (
76 | {
79 | if (process.env.EXPO_OS === "ios") {
80 | // Add a soft haptic feedback when pressing down on the tabs.
81 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
82 | }
83 | props.onPressIn?.(ev);
84 | }}
85 | />
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/components/ui/ThemeProvider.tsx:
--------------------------------------------------------------------------------
1 | // import * as AppleColors from "@bacons/apple-colors";
2 | import {
3 | DarkTheme,
4 | DefaultTheme,
5 | ThemeProvider as RNTheme,
6 | } from "@react-navigation/native";
7 | import { useColorScheme } from "react-native";
8 |
9 | // Use exact native P3 colors and equivalents on Android/web.
10 | // This lines up well with React Navigation.
11 | // const BaconDefaultTheme: Theme = {
12 | // dark: false,
13 | // colors: {
14 | // primary: AppleColors.systemBlue,
15 | // notification: AppleColors.systemRed,
16 | // ...DefaultTheme.colors,
17 | // // background: AppleColors.systemGroupedBackground,
18 | // // card: AppleColors.secondarySystemGroupedBackground,
19 | // // text: AppleColors.label,
20 | // // border: AppleColors.separator,
21 | // },
22 | // fonts: DefaultTheme.fonts,
23 | // };
24 |
25 | // const BaconDarkTheme: Theme = {
26 | // dark: true,
27 | // colors: {
28 | // // ...BaconDefaultTheme.colors,
29 | // ...DarkTheme.colors,
30 | // },
31 | // fonts: DarkTheme.fonts,
32 | // };
33 |
34 | export default function ThemeProvider(props: { children: React.ReactNode }) {
35 | const colorScheme = process.env.EXPO_OS === "web" ? "dark" : useColorScheme();
36 | return (
37 |
43 | {props.children}
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/components/ui/TouchableBounce.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | // @ts-expect-error: untyped helper
4 | import RNTouchableBounce from "react-native/Libraries/Components/Touchable/TouchableBounce";
5 |
6 | import {
7 | TouchableOpacityProps as RNTouchableOpacityProps,
8 | View,
9 | } from "react-native";
10 |
11 | import * as Haptics from "expo-haptics";
12 | import * as React from "react";
13 |
14 | export type TouchableScaleProps = Omit<
15 | RNTouchableOpacityProps,
16 | "activeOpacity"
17 | > & {
18 | /** Enables haptic feedback on press down. */
19 | sensory?:
20 | | boolean
21 | | "success"
22 | | "error"
23 | | "warning"
24 | | "light"
25 | | "medium"
26 | | "heavy";
27 | };
28 |
29 | /**
30 | * Touchable which scales the children down when pressed.
31 | */
32 | export default function TouchableBounce({
33 | style,
34 | children,
35 | onPressIn,
36 | sensory,
37 | ...props
38 | }: TouchableScaleProps) {
39 | const onSensory = React.useCallback(() => {
40 | if (!sensory) return;
41 | if (sensory === true) {
42 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
43 | } else if (sensory === "success") {
44 | Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
45 | } else if (sensory === "error") {
46 | Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
47 | } else if (sensory === "warning") {
48 | Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
49 | } else if (sensory === "light") {
50 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
51 | } else if (sensory === "medium") {
52 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
53 | } else if (sensory === "heavy") {
54 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
55 | }
56 | }, [sensory]);
57 |
58 | return (
59 | {
62 | onSensory();
63 | onPressIn?.(ev);
64 | }}
65 | >
66 | {children ? children : }
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/components/ui/TouchableBounce.web.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { TouchableOpacity } from "react-native";
4 |
5 | export default TouchableOpacity;
6 |
--------------------------------------------------------------------------------
/cors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | *
5 | POST
6 | GET
7 | 3000
8 | Authorization
9 |
10 |
--------------------------------------------------------------------------------
/db/index.ts:
--------------------------------------------------------------------------------
1 | import { drizzle } from "drizzle-orm/postgres-js";
2 | import postgres from "postgres";
3 | import * as schema from "./schema";
4 |
5 | const client = postgres(process.env.DATABASE_URL!);
6 | export const db = drizzle(client, { schema });
7 |
--------------------------------------------------------------------------------
/db/schema.ts:
--------------------------------------------------------------------------------
1 | import { relations } from "drizzle-orm";
2 | import { index, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
3 |
4 | export const users = pgTable("users", {
5 | id: uuid("id").primaryKey().defaultRandom(),
6 | email: varchar("email", { length: 255 }).notNull().unique(),
7 | password: varchar("password", { length: 255 }).notNull(),
8 | createdAt: timestamp("created_at").notNull().defaultNow(),
9 | });
10 |
11 | export const profiles = pgTable(
12 | "profiles",
13 | {
14 | id: uuid("id").primaryKey().defaultRandom(),
15 | userId: uuid("user_id").notNull().unique(),
16 | displayName: varchar("display_name", { length: 255 }).notNull(),
17 | createdAt: timestamp("created_at").notNull().defaultNow(),
18 | imageId: uuid("image_id"),
19 | },
20 | (t) => ({
21 | userIdIdx: index("user_id_idx").on(t.userId),
22 | })
23 | );
24 |
25 | export const posts = pgTable(
26 | "posts",
27 | {
28 | id: uuid("id").primaryKey().defaultRandom(),
29 | userId: uuid("user_id").notNull(),
30 | text: varchar("text", { length: 255 }).notNull(),
31 | createdAt: timestamp("created_at").notNull().defaultNow(),
32 | },
33 | (t) => ({
34 | userIdIdx: index("posts_user_id_idx").on(t.userId),
35 | })
36 | );
37 |
38 | export const followers = pgTable(
39 | "followers",
40 | {
41 | id: uuid("id").primaryKey().defaultRandom(),
42 | userId: uuid("user_id").notNull(),
43 | followingId: uuid("following_id").notNull(),
44 | createdAt: timestamp("created_at").notNull().defaultNow(),
45 | },
46 | (t) => ({
47 | userIdIdx: index("followers_user_id_idx").on(t.userId),
48 | followingIdIdx: index("followers_following_id_idx").on(t.followingId),
49 | })
50 | );
51 |
52 | export const notifications = pgTable(
53 | "notifications",
54 | {
55 | id: uuid("id").primaryKey().defaultRandom(),
56 | userId: uuid("user_id").notNull(),
57 | fromUserId: uuid("from_user_id").notNull(),
58 | type: varchar("type", { length: 255 }).notNull(),
59 | createdAt: timestamp("created_at").notNull().defaultNow(),
60 | content: varchar("content", { length: 255 }),
61 | },
62 | (t) => ({
63 | userIdIdx: index("notifications_user_id_idx").on(t.userId),
64 | fromUserIdIdx: index("notifications_from_user_id_idx").on(t.fromUserId),
65 | })
66 | );
67 |
68 | export const userRelations = relations(users, ({ one, many }) => ({
69 | profile: one(profiles, {
70 | fields: [users.id],
71 | references: [profiles.userId],
72 | }),
73 | }));
74 |
75 | export const profileRelations = relations(profiles, ({ one, many }) => ({
76 | user: one(users, {
77 | fields: [profiles.userId],
78 | references: [users.id],
79 | }),
80 | }));
81 |
82 | export const postRelations = relations(posts, ({ one }) => ({
83 | profile: one(profiles, {
84 | fields: [posts.userId],
85 | references: [profiles.userId],
86 | }),
87 | }));
88 |
89 | export type Post = typeof posts.$inferSelect;
90 | export type Profile = typeof profiles.$inferSelect;
91 | export type User = typeof users.$inferSelect;
92 | export type Follower = typeof followers.$inferSelect;
93 | export type Notification = typeof notifications.$inferSelect;
94 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 | services:
3 | the-get-social-db:
4 | image: postgres
5 | restart: always
6 | container_name: the-get-social-db
7 | ports:
8 | - 5432:5432
9 | environment:
10 | POSTGRES_PASSWORD: example
11 | PGDATA: /data/postgres
12 | volumes:
13 | - postgres:/data/postgres
14 |
15 | get-social-bucket:
16 | build: .
17 | restart: always
18 | ports:
19 | - 9000:9000
20 | command: "node s3.mjs"
21 | environment:
22 | AWS_ACCESS_KEY_ID: S3RVER
23 | AWS_SECRET_ACCESS_KEY: S3RVER
24 | volumes:
25 | - ".:/home/app"
26 |
27 | volumes:
28 | postgres:
29 |
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, type Config } from "drizzle-kit";
2 | import "dotenv/config";
3 |
4 | export default defineConfig({
5 | schema: "./db/schema.ts",
6 | out: "./migrations",
7 | dialect: "postgresql",
8 | dbCredentials: {
9 | url: process.env.DATABASE_URL!,
10 | },
11 | }) as Config;
12 |
--------------------------------------------------------------------------------
/hooks/useAuth.tsx:
--------------------------------------------------------------------------------
1 | import { secureGet } from "@/utils/storage";
2 | import { createContext, useContext, useEffect, useState } from "react";
3 |
4 | const AuthContext = createContext<{
5 | token: string;
6 | isLoading: boolean;
7 | setToken: (token: string) => void;
8 | setIsLoading: (isLoading: boolean) => void;
9 | }>({ token: "", isLoading: true, setToken: () => {}, setIsLoading: () => {} });
10 |
11 | export function AuthProvider({ children }: { children: React.ReactNode }) {
12 | const [token, setToken] = useState("");
13 | const [isLoading, setIsLoading] = useState(true);
14 |
15 | return (
16 |
17 | {children}
18 |
19 | );
20 | }
21 |
22 | export function useAuth() {
23 | const { token, isLoading, setToken, setIsLoading } = useContext(AuthContext);
24 |
25 | useEffect(() => {
26 | const fetchToken = async () => {
27 | const token = await secureGet("token");
28 | setToken(token || "");
29 | setIsLoading(false);
30 | };
31 | fetchToken();
32 | }, []);
33 |
34 | return { token, isLoggedIn: !!token, isLoading };
35 | }
36 |
--------------------------------------------------------------------------------
/hooks/useHeaderSearch.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import { useEffect, useState } from "react";
4 | import { useNavigation } from "expo-router";
5 | import { SearchBarProps } from "react-native-screens";
6 |
7 | export function useHeaderSearch(options: Omit = {}) {
8 | const [search, setSearch] = useState("");
9 | const navigation = useNavigation();
10 |
11 | useEffect(() => {
12 | const interceptedOptions: SearchBarProps = {
13 | ...options,
14 | onChangeText(event) {
15 | setSearch(event.nativeEvent.text);
16 | options.onChangeText?.(event);
17 | },
18 | onSearchButtonPress(e) {
19 | setSearch(e.nativeEvent.text);
20 | options.onSearchButtonPress?.(e);
21 | },
22 | onCancelButtonPress(e) {
23 | setSearch("");
24 | options.onCancelButtonPress?.(e);
25 | },
26 | };
27 |
28 | navigation.setOptions({
29 | headerShown: true,
30 | headerSearchBarOptions: interceptedOptions,
31 | });
32 | }, [options, navigation]);
33 |
34 | return search;
35 | }
36 |
--------------------------------------------------------------------------------
/hooks/useMergedRef.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 |
3 | type CallbackRef = (ref: T) => any;
4 | type ObjectRef = { current: T };
5 |
6 | type Ref = CallbackRef | ObjectRef;
7 |
8 | export default function useMergedRef(
9 | ...refs: (Ref | undefined)[]
10 | ): CallbackRef {
11 | return useCallback(
12 | (current: T) => {
13 | for (const ref of refs) {
14 | if (ref != null) {
15 | if (typeof ref === "function") {
16 | ref(current);
17 | } else {
18 | ref.current = current;
19 | }
20 | }
21 | }
22 | },
23 | [...refs]
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/hooks/useTabToTop.ts:
--------------------------------------------------------------------------------
1 | import {
2 | EventArg,
3 | NavigationProp,
4 | useNavigation,
5 | useRoute,
6 | } from "@react-navigation/core";
7 | import * as React from "react";
8 | import type { ScrollView } from "react-native";
9 | import type { WebView } from "react-native-webview";
10 |
11 | type ScrollOptions = { x?: number; y?: number; animated?: boolean };
12 |
13 | type ScrollableView =
14 | | { scrollToTop(): void }
15 | | { scrollTo(options: ScrollOptions): void }
16 | | { scrollToOffset(options: { offset?: number; animated?: boolean }): void }
17 | | { scrollResponderScrollTo(options: ScrollOptions): void };
18 |
19 | type ScrollableWrapper =
20 | | { getScrollResponder(): React.ReactNode | ScrollView }
21 | | { getNode(): ScrollableView }
22 | | ScrollableView;
23 |
24 | function getScrollableNode(
25 | ref: React.RefObject | React.RefObject
26 | ) {
27 | if (ref.current == null) {
28 | return null;
29 | }
30 |
31 | if (
32 | "scrollToTop" in ref.current ||
33 | "scrollTo" in ref.current ||
34 | "scrollToOffset" in ref.current ||
35 | "scrollResponderScrollTo" in ref.current
36 | ) {
37 | // This is already a scrollable node.
38 | return ref.current;
39 | } else if ("getScrollResponder" in ref.current) {
40 | // If the view is a wrapper like FlatList, SectionList etc.
41 | // We need to use `getScrollResponder` to get access to the scroll responder
42 | return ref.current.getScrollResponder();
43 | } else if ("getNode" in ref.current) {
44 | // When a `ScrollView` is wraped in `Animated.createAnimatedComponent`
45 | // we need to use `getNode` to get the ref to the actual scrollview.
46 | // Note that `getNode` is deprecated in newer versions of react-native
47 | // this is why we check if we already have a scrollable node above.
48 | return ref.current.getNode();
49 | } else {
50 | return ref.current;
51 | }
52 | }
53 |
54 | export function useScrollToTop(
55 | ref: React.RefObject | React.RefObject,
56 | offset: number = 0
57 | ) {
58 | const navigation = useNavigation();
59 | const route = useRoute();
60 |
61 | React.useEffect(() => {
62 | let tabNavigations: NavigationProp[] = [];
63 | let currentNavigation = navigation;
64 |
65 | // If the screen is nested inside multiple tab navigators, we should scroll to top for any of them
66 | // So we need to find all the parent tab navigators and add the listeners there
67 | while (currentNavigation) {
68 | if (currentNavigation.getState().type === "tab") {
69 | tabNavigations.push(currentNavigation);
70 | }
71 |
72 | currentNavigation = currentNavigation.getParent();
73 | }
74 |
75 | if (tabNavigations.length === 0) {
76 | return;
77 | }
78 |
79 | const unsubscribers = tabNavigations.map((tab) => {
80 | return tab.addListener(
81 | // We don't wanna import tab types here to avoid extra deps
82 | // in addition, there are multiple tab implementations
83 | // @ts-expect-error
84 | "tabPress",
85 | (e: EventArg<"tabPress", true>) => {
86 | // We should scroll to top only when the screen is focused
87 | const isFocused = navigation.isFocused();
88 |
89 | // In a nested stack navigator, tab press resets the stack to first screen
90 | // So we should scroll to top only when we are on first screen
91 | const isFirst =
92 | tabNavigations.includes(navigation) ||
93 | navigation.getState().routes[0].key === route.key;
94 |
95 | // Run the operation in the next frame so we're sure all listeners have been run
96 | // This is necessary to know if preventDefault() has been called
97 | requestAnimationFrame(() => {
98 | const scrollable = getScrollableNode(ref) as
99 | | ScrollableWrapper
100 | | WebView;
101 |
102 | if (isFocused && isFirst && scrollable && !e.defaultPrevented) {
103 | if ("scrollToTop" in scrollable) {
104 | scrollable.scrollToTop();
105 | } else if ("scrollTo" in scrollable) {
106 | scrollable.scrollTo({ y: offset, animated: true });
107 | } else if ("scrollToOffset" in scrollable) {
108 | scrollable.scrollToOffset({ offset: offset, animated: true });
109 | } else if ("scrollResponderScrollTo" in scrollable) {
110 | scrollable.scrollResponderScrollTo({
111 | y: offset,
112 | animated: true,
113 | });
114 | } else if ("injectJavaScript" in scrollable) {
115 | scrollable.injectJavaScript(
116 | `;window.scrollTo({ top: ${offset}, behavior: 'smooth' }); true;`
117 | );
118 | }
119 | }
120 | });
121 | }
122 | );
123 | });
124 |
125 | return () => {
126 | unsubscribers.forEach((unsubscribe) => unsubscribe());
127 | };
128 | }, [navigation, ref, offset, route.key]);
129 | }
130 |
131 | export const useScrollRef =
132 | process.env.EXPO_OS === "web"
133 | ? () => undefined
134 | : () => {
135 | const ref = React.useRef(null);
136 |
137 | useScrollToTop(ref);
138 |
139 | return ref;
140 | };
141 |
--------------------------------------------------------------------------------
/migrations/0000_soft_electro.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "posts" (
2 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3 | "user_id" uuid NOT NULL,
4 | "text" varchar(255) NOT NULL,
5 | "created_at" timestamp DEFAULT now() NOT NULL
6 | );
7 | --> statement-breakpoint
8 | CREATE TABLE "profiles" (
9 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
10 | "user_id" uuid NOT NULL,
11 | "display_name" varchar(255) NOT NULL,
12 | "created_at" timestamp DEFAULT now() NOT NULL,
13 | CONSTRAINT "profiles_user_id_unique" UNIQUE("user_id")
14 | );
15 | --> statement-breakpoint
16 | CREATE TABLE "users" (
17 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
18 | "email" varchar(255) NOT NULL,
19 | "password" varchar(255) NOT NULL,
20 | "created_at" timestamp DEFAULT now() NOT NULL,
21 | CONSTRAINT "users_email_unique" UNIQUE("email")
22 | );
23 | --> statement-breakpoint
24 | CREATE INDEX "posts_user_id_idx" ON "posts" USING btree ("user_id");--> statement-breakpoint
25 | CREATE INDEX "user_id_idx" ON "profiles" USING btree ("user_id");
--------------------------------------------------------------------------------
/migrations/0001_remarkable_argent.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "followers" (
2 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3 | "user_id" uuid NOT NULL,
4 | "following_id" uuid NOT NULL,
5 | "created_at" timestamp DEFAULT now() NOT NULL
6 | );
7 | --> statement-breakpoint
8 | CREATE INDEX "followers_user_id_idx" ON "followers" USING btree ("user_id");--> statement-breakpoint
9 | CREATE INDEX "followers_following_id_idx" ON "followers" USING btree ("following_id");
--------------------------------------------------------------------------------
/migrations/0002_familiar_rogue.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "notifications" (
2 | "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3 | "user_id" uuid NOT NULL,
4 | "from_user_id" uuid NOT NULL,
5 | "type" varchar(255) NOT NULL,
6 | "created_at" timestamp DEFAULT now() NOT NULL
7 | );
8 | --> statement-breakpoint
9 | CREATE INDEX "notifications_user_id_idx" ON "notifications" USING btree ("user_id");--> statement-breakpoint
10 | CREATE INDEX "notifications_from_user_id_idx" ON "notifications" USING btree ("from_user_id");
--------------------------------------------------------------------------------
/migrations/0003_flat_lord_tyger.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "notifications" ADD COLUMN "content" varchar(255);
--------------------------------------------------------------------------------
/migrations/0004_flowery_young_avengers.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "profiles" ADD COLUMN "image_id" uuid;
--------------------------------------------------------------------------------
/migrations/meta/0000_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "fa4f6fcc-03ab-4ebd-a138-598800407624",
3 | "prevId": "00000000-0000-0000-0000-000000000000",
4 | "version": "7",
5 | "dialect": "postgresql",
6 | "tables": {
7 | "public.posts": {
8 | "name": "posts",
9 | "schema": "",
10 | "columns": {
11 | "id": {
12 | "name": "id",
13 | "type": "uuid",
14 | "primaryKey": true,
15 | "notNull": true,
16 | "default": "gen_random_uuid()"
17 | },
18 | "user_id": {
19 | "name": "user_id",
20 | "type": "uuid",
21 | "primaryKey": false,
22 | "notNull": true
23 | },
24 | "text": {
25 | "name": "text",
26 | "type": "varchar(255)",
27 | "primaryKey": false,
28 | "notNull": true
29 | },
30 | "created_at": {
31 | "name": "created_at",
32 | "type": "timestamp",
33 | "primaryKey": false,
34 | "notNull": true,
35 | "default": "now()"
36 | }
37 | },
38 | "indexes": {
39 | "posts_user_id_idx": {
40 | "name": "posts_user_id_idx",
41 | "columns": [
42 | {
43 | "expression": "user_id",
44 | "isExpression": false,
45 | "asc": true,
46 | "nulls": "last"
47 | }
48 | ],
49 | "isUnique": false,
50 | "concurrently": false,
51 | "method": "btree",
52 | "with": {}
53 | }
54 | },
55 | "foreignKeys": {},
56 | "compositePrimaryKeys": {},
57 | "uniqueConstraints": {},
58 | "policies": {},
59 | "checkConstraints": {},
60 | "isRLSEnabled": false
61 | },
62 | "public.profiles": {
63 | "name": "profiles",
64 | "schema": "",
65 | "columns": {
66 | "id": {
67 | "name": "id",
68 | "type": "uuid",
69 | "primaryKey": true,
70 | "notNull": true,
71 | "default": "gen_random_uuid()"
72 | },
73 | "user_id": {
74 | "name": "user_id",
75 | "type": "uuid",
76 | "primaryKey": false,
77 | "notNull": true
78 | },
79 | "display_name": {
80 | "name": "display_name",
81 | "type": "varchar(255)",
82 | "primaryKey": false,
83 | "notNull": true
84 | },
85 | "created_at": {
86 | "name": "created_at",
87 | "type": "timestamp",
88 | "primaryKey": false,
89 | "notNull": true,
90 | "default": "now()"
91 | }
92 | },
93 | "indexes": {
94 | "user_id_idx": {
95 | "name": "user_id_idx",
96 | "columns": [
97 | {
98 | "expression": "user_id",
99 | "isExpression": false,
100 | "asc": true,
101 | "nulls": "last"
102 | }
103 | ],
104 | "isUnique": false,
105 | "concurrently": false,
106 | "method": "btree",
107 | "with": {}
108 | }
109 | },
110 | "foreignKeys": {},
111 | "compositePrimaryKeys": {},
112 | "uniqueConstraints": {
113 | "profiles_user_id_unique": {
114 | "name": "profiles_user_id_unique",
115 | "nullsNotDistinct": false,
116 | "columns": [
117 | "user_id"
118 | ]
119 | }
120 | },
121 | "policies": {},
122 | "checkConstraints": {},
123 | "isRLSEnabled": false
124 | },
125 | "public.users": {
126 | "name": "users",
127 | "schema": "",
128 | "columns": {
129 | "id": {
130 | "name": "id",
131 | "type": "uuid",
132 | "primaryKey": true,
133 | "notNull": true,
134 | "default": "gen_random_uuid()"
135 | },
136 | "email": {
137 | "name": "email",
138 | "type": "varchar(255)",
139 | "primaryKey": false,
140 | "notNull": true
141 | },
142 | "password": {
143 | "name": "password",
144 | "type": "varchar(255)",
145 | "primaryKey": false,
146 | "notNull": true
147 | },
148 | "created_at": {
149 | "name": "created_at",
150 | "type": "timestamp",
151 | "primaryKey": false,
152 | "notNull": true,
153 | "default": "now()"
154 | }
155 | },
156 | "indexes": {},
157 | "foreignKeys": {},
158 | "compositePrimaryKeys": {},
159 | "uniqueConstraints": {
160 | "users_email_unique": {
161 | "name": "users_email_unique",
162 | "nullsNotDistinct": false,
163 | "columns": [
164 | "email"
165 | ]
166 | }
167 | },
168 | "policies": {},
169 | "checkConstraints": {},
170 | "isRLSEnabled": false
171 | }
172 | },
173 | "enums": {},
174 | "schemas": {},
175 | "sequences": {},
176 | "roles": {},
177 | "policies": {},
178 | "views": {},
179 | "_meta": {
180 | "columns": {},
181 | "schemas": {},
182 | "tables": {}
183 | }
184 | }
--------------------------------------------------------------------------------
/migrations/meta/0001_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "99b6919e-e9d4-4ac8-b86c-a754c4696db6",
3 | "prevId": "fa4f6fcc-03ab-4ebd-a138-598800407624",
4 | "version": "7",
5 | "dialect": "postgresql",
6 | "tables": {
7 | "public.followers": {
8 | "name": "followers",
9 | "schema": "",
10 | "columns": {
11 | "id": {
12 | "name": "id",
13 | "type": "uuid",
14 | "primaryKey": true,
15 | "notNull": true,
16 | "default": "gen_random_uuid()"
17 | },
18 | "user_id": {
19 | "name": "user_id",
20 | "type": "uuid",
21 | "primaryKey": false,
22 | "notNull": true
23 | },
24 | "following_id": {
25 | "name": "following_id",
26 | "type": "uuid",
27 | "primaryKey": false,
28 | "notNull": true
29 | },
30 | "created_at": {
31 | "name": "created_at",
32 | "type": "timestamp",
33 | "primaryKey": false,
34 | "notNull": true,
35 | "default": "now()"
36 | }
37 | },
38 | "indexes": {
39 | "followers_user_id_idx": {
40 | "name": "followers_user_id_idx",
41 | "columns": [
42 | {
43 | "expression": "user_id",
44 | "isExpression": false,
45 | "asc": true,
46 | "nulls": "last"
47 | }
48 | ],
49 | "isUnique": false,
50 | "concurrently": false,
51 | "method": "btree",
52 | "with": {}
53 | },
54 | "followers_following_id_idx": {
55 | "name": "followers_following_id_idx",
56 | "columns": [
57 | {
58 | "expression": "following_id",
59 | "isExpression": false,
60 | "asc": true,
61 | "nulls": "last"
62 | }
63 | ],
64 | "isUnique": false,
65 | "concurrently": false,
66 | "method": "btree",
67 | "with": {}
68 | }
69 | },
70 | "foreignKeys": {},
71 | "compositePrimaryKeys": {},
72 | "uniqueConstraints": {},
73 | "policies": {},
74 | "checkConstraints": {},
75 | "isRLSEnabled": false
76 | },
77 | "public.posts": {
78 | "name": "posts",
79 | "schema": "",
80 | "columns": {
81 | "id": {
82 | "name": "id",
83 | "type": "uuid",
84 | "primaryKey": true,
85 | "notNull": true,
86 | "default": "gen_random_uuid()"
87 | },
88 | "user_id": {
89 | "name": "user_id",
90 | "type": "uuid",
91 | "primaryKey": false,
92 | "notNull": true
93 | },
94 | "text": {
95 | "name": "text",
96 | "type": "varchar(255)",
97 | "primaryKey": false,
98 | "notNull": true
99 | },
100 | "created_at": {
101 | "name": "created_at",
102 | "type": "timestamp",
103 | "primaryKey": false,
104 | "notNull": true,
105 | "default": "now()"
106 | }
107 | },
108 | "indexes": {
109 | "posts_user_id_idx": {
110 | "name": "posts_user_id_idx",
111 | "columns": [
112 | {
113 | "expression": "user_id",
114 | "isExpression": false,
115 | "asc": true,
116 | "nulls": "last"
117 | }
118 | ],
119 | "isUnique": false,
120 | "concurrently": false,
121 | "method": "btree",
122 | "with": {}
123 | }
124 | },
125 | "foreignKeys": {},
126 | "compositePrimaryKeys": {},
127 | "uniqueConstraints": {},
128 | "policies": {},
129 | "checkConstraints": {},
130 | "isRLSEnabled": false
131 | },
132 | "public.profiles": {
133 | "name": "profiles",
134 | "schema": "",
135 | "columns": {
136 | "id": {
137 | "name": "id",
138 | "type": "uuid",
139 | "primaryKey": true,
140 | "notNull": true,
141 | "default": "gen_random_uuid()"
142 | },
143 | "user_id": {
144 | "name": "user_id",
145 | "type": "uuid",
146 | "primaryKey": false,
147 | "notNull": true
148 | },
149 | "display_name": {
150 | "name": "display_name",
151 | "type": "varchar(255)",
152 | "primaryKey": false,
153 | "notNull": true
154 | },
155 | "created_at": {
156 | "name": "created_at",
157 | "type": "timestamp",
158 | "primaryKey": false,
159 | "notNull": true,
160 | "default": "now()"
161 | }
162 | },
163 | "indexes": {
164 | "user_id_idx": {
165 | "name": "user_id_idx",
166 | "columns": [
167 | {
168 | "expression": "user_id",
169 | "isExpression": false,
170 | "asc": true,
171 | "nulls": "last"
172 | }
173 | ],
174 | "isUnique": false,
175 | "concurrently": false,
176 | "method": "btree",
177 | "with": {}
178 | }
179 | },
180 | "foreignKeys": {},
181 | "compositePrimaryKeys": {},
182 | "uniqueConstraints": {
183 | "profiles_user_id_unique": {
184 | "name": "profiles_user_id_unique",
185 | "nullsNotDistinct": false,
186 | "columns": [
187 | "user_id"
188 | ]
189 | }
190 | },
191 | "policies": {},
192 | "checkConstraints": {},
193 | "isRLSEnabled": false
194 | },
195 | "public.users": {
196 | "name": "users",
197 | "schema": "",
198 | "columns": {
199 | "id": {
200 | "name": "id",
201 | "type": "uuid",
202 | "primaryKey": true,
203 | "notNull": true,
204 | "default": "gen_random_uuid()"
205 | },
206 | "email": {
207 | "name": "email",
208 | "type": "varchar(255)",
209 | "primaryKey": false,
210 | "notNull": true
211 | },
212 | "password": {
213 | "name": "password",
214 | "type": "varchar(255)",
215 | "primaryKey": false,
216 | "notNull": true
217 | },
218 | "created_at": {
219 | "name": "created_at",
220 | "type": "timestamp",
221 | "primaryKey": false,
222 | "notNull": true,
223 | "default": "now()"
224 | }
225 | },
226 | "indexes": {},
227 | "foreignKeys": {},
228 | "compositePrimaryKeys": {},
229 | "uniqueConstraints": {
230 | "users_email_unique": {
231 | "name": "users_email_unique",
232 | "nullsNotDistinct": false,
233 | "columns": [
234 | "email"
235 | ]
236 | }
237 | },
238 | "policies": {},
239 | "checkConstraints": {},
240 | "isRLSEnabled": false
241 | }
242 | },
243 | "enums": {},
244 | "schemas": {},
245 | "sequences": {},
246 | "roles": {},
247 | "policies": {},
248 | "views": {},
249 | "_meta": {
250 | "columns": {},
251 | "schemas": {},
252 | "tables": {}
253 | }
254 | }
--------------------------------------------------------------------------------
/migrations/meta/0002_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "654fe874-9e19-45ba-8381-1a4f2a6711ee",
3 | "prevId": "99b6919e-e9d4-4ac8-b86c-a754c4696db6",
4 | "version": "7",
5 | "dialect": "postgresql",
6 | "tables": {
7 | "public.followers": {
8 | "name": "followers",
9 | "schema": "",
10 | "columns": {
11 | "id": {
12 | "name": "id",
13 | "type": "uuid",
14 | "primaryKey": true,
15 | "notNull": true,
16 | "default": "gen_random_uuid()"
17 | },
18 | "user_id": {
19 | "name": "user_id",
20 | "type": "uuid",
21 | "primaryKey": false,
22 | "notNull": true
23 | },
24 | "following_id": {
25 | "name": "following_id",
26 | "type": "uuid",
27 | "primaryKey": false,
28 | "notNull": true
29 | },
30 | "created_at": {
31 | "name": "created_at",
32 | "type": "timestamp",
33 | "primaryKey": false,
34 | "notNull": true,
35 | "default": "now()"
36 | }
37 | },
38 | "indexes": {
39 | "followers_user_id_idx": {
40 | "name": "followers_user_id_idx",
41 | "columns": [
42 | {
43 | "expression": "user_id",
44 | "isExpression": false,
45 | "asc": true,
46 | "nulls": "last"
47 | }
48 | ],
49 | "isUnique": false,
50 | "concurrently": false,
51 | "method": "btree",
52 | "with": {}
53 | },
54 | "followers_following_id_idx": {
55 | "name": "followers_following_id_idx",
56 | "columns": [
57 | {
58 | "expression": "following_id",
59 | "isExpression": false,
60 | "asc": true,
61 | "nulls": "last"
62 | }
63 | ],
64 | "isUnique": false,
65 | "concurrently": false,
66 | "method": "btree",
67 | "with": {}
68 | }
69 | },
70 | "foreignKeys": {},
71 | "compositePrimaryKeys": {},
72 | "uniqueConstraints": {},
73 | "policies": {},
74 | "checkConstraints": {},
75 | "isRLSEnabled": false
76 | },
77 | "public.notifications": {
78 | "name": "notifications",
79 | "schema": "",
80 | "columns": {
81 | "id": {
82 | "name": "id",
83 | "type": "uuid",
84 | "primaryKey": true,
85 | "notNull": true,
86 | "default": "gen_random_uuid()"
87 | },
88 | "user_id": {
89 | "name": "user_id",
90 | "type": "uuid",
91 | "primaryKey": false,
92 | "notNull": true
93 | },
94 | "from_user_id": {
95 | "name": "from_user_id",
96 | "type": "uuid",
97 | "primaryKey": false,
98 | "notNull": true
99 | },
100 | "type": {
101 | "name": "type",
102 | "type": "varchar(255)",
103 | "primaryKey": false,
104 | "notNull": true
105 | },
106 | "created_at": {
107 | "name": "created_at",
108 | "type": "timestamp",
109 | "primaryKey": false,
110 | "notNull": true,
111 | "default": "now()"
112 | }
113 | },
114 | "indexes": {
115 | "notifications_user_id_idx": {
116 | "name": "notifications_user_id_idx",
117 | "columns": [
118 | {
119 | "expression": "user_id",
120 | "isExpression": false,
121 | "asc": true,
122 | "nulls": "last"
123 | }
124 | ],
125 | "isUnique": false,
126 | "concurrently": false,
127 | "method": "btree",
128 | "with": {}
129 | },
130 | "notifications_from_user_id_idx": {
131 | "name": "notifications_from_user_id_idx",
132 | "columns": [
133 | {
134 | "expression": "from_user_id",
135 | "isExpression": false,
136 | "asc": true,
137 | "nulls": "last"
138 | }
139 | ],
140 | "isUnique": false,
141 | "concurrently": false,
142 | "method": "btree",
143 | "with": {}
144 | }
145 | },
146 | "foreignKeys": {},
147 | "compositePrimaryKeys": {},
148 | "uniqueConstraints": {},
149 | "policies": {},
150 | "checkConstraints": {},
151 | "isRLSEnabled": false
152 | },
153 | "public.posts": {
154 | "name": "posts",
155 | "schema": "",
156 | "columns": {
157 | "id": {
158 | "name": "id",
159 | "type": "uuid",
160 | "primaryKey": true,
161 | "notNull": true,
162 | "default": "gen_random_uuid()"
163 | },
164 | "user_id": {
165 | "name": "user_id",
166 | "type": "uuid",
167 | "primaryKey": false,
168 | "notNull": true
169 | },
170 | "text": {
171 | "name": "text",
172 | "type": "varchar(255)",
173 | "primaryKey": false,
174 | "notNull": true
175 | },
176 | "created_at": {
177 | "name": "created_at",
178 | "type": "timestamp",
179 | "primaryKey": false,
180 | "notNull": true,
181 | "default": "now()"
182 | }
183 | },
184 | "indexes": {
185 | "posts_user_id_idx": {
186 | "name": "posts_user_id_idx",
187 | "columns": [
188 | {
189 | "expression": "user_id",
190 | "isExpression": false,
191 | "asc": true,
192 | "nulls": "last"
193 | }
194 | ],
195 | "isUnique": false,
196 | "concurrently": false,
197 | "method": "btree",
198 | "with": {}
199 | }
200 | },
201 | "foreignKeys": {},
202 | "compositePrimaryKeys": {},
203 | "uniqueConstraints": {},
204 | "policies": {},
205 | "checkConstraints": {},
206 | "isRLSEnabled": false
207 | },
208 | "public.profiles": {
209 | "name": "profiles",
210 | "schema": "",
211 | "columns": {
212 | "id": {
213 | "name": "id",
214 | "type": "uuid",
215 | "primaryKey": true,
216 | "notNull": true,
217 | "default": "gen_random_uuid()"
218 | },
219 | "user_id": {
220 | "name": "user_id",
221 | "type": "uuid",
222 | "primaryKey": false,
223 | "notNull": true
224 | },
225 | "display_name": {
226 | "name": "display_name",
227 | "type": "varchar(255)",
228 | "primaryKey": false,
229 | "notNull": true
230 | },
231 | "created_at": {
232 | "name": "created_at",
233 | "type": "timestamp",
234 | "primaryKey": false,
235 | "notNull": true,
236 | "default": "now()"
237 | }
238 | },
239 | "indexes": {
240 | "user_id_idx": {
241 | "name": "user_id_idx",
242 | "columns": [
243 | {
244 | "expression": "user_id",
245 | "isExpression": false,
246 | "asc": true,
247 | "nulls": "last"
248 | }
249 | ],
250 | "isUnique": false,
251 | "concurrently": false,
252 | "method": "btree",
253 | "with": {}
254 | }
255 | },
256 | "foreignKeys": {},
257 | "compositePrimaryKeys": {},
258 | "uniqueConstraints": {
259 | "profiles_user_id_unique": {
260 | "name": "profiles_user_id_unique",
261 | "nullsNotDistinct": false,
262 | "columns": [
263 | "user_id"
264 | ]
265 | }
266 | },
267 | "policies": {},
268 | "checkConstraints": {},
269 | "isRLSEnabled": false
270 | },
271 | "public.users": {
272 | "name": "users",
273 | "schema": "",
274 | "columns": {
275 | "id": {
276 | "name": "id",
277 | "type": "uuid",
278 | "primaryKey": true,
279 | "notNull": true,
280 | "default": "gen_random_uuid()"
281 | },
282 | "email": {
283 | "name": "email",
284 | "type": "varchar(255)",
285 | "primaryKey": false,
286 | "notNull": true
287 | },
288 | "password": {
289 | "name": "password",
290 | "type": "varchar(255)",
291 | "primaryKey": false,
292 | "notNull": true
293 | },
294 | "created_at": {
295 | "name": "created_at",
296 | "type": "timestamp",
297 | "primaryKey": false,
298 | "notNull": true,
299 | "default": "now()"
300 | }
301 | },
302 | "indexes": {},
303 | "foreignKeys": {},
304 | "compositePrimaryKeys": {},
305 | "uniqueConstraints": {
306 | "users_email_unique": {
307 | "name": "users_email_unique",
308 | "nullsNotDistinct": false,
309 | "columns": [
310 | "email"
311 | ]
312 | }
313 | },
314 | "policies": {},
315 | "checkConstraints": {},
316 | "isRLSEnabled": false
317 | }
318 | },
319 | "enums": {},
320 | "schemas": {},
321 | "sequences": {},
322 | "roles": {},
323 | "policies": {},
324 | "views": {},
325 | "_meta": {
326 | "columns": {},
327 | "schemas": {},
328 | "tables": {}
329 | }
330 | }
--------------------------------------------------------------------------------
/migrations/meta/0003_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "c9df29e5-653b-46c6-a9a2-e86fbb334841",
3 | "prevId": "654fe874-9e19-45ba-8381-1a4f2a6711ee",
4 | "version": "7",
5 | "dialect": "postgresql",
6 | "tables": {
7 | "public.followers": {
8 | "name": "followers",
9 | "schema": "",
10 | "columns": {
11 | "id": {
12 | "name": "id",
13 | "type": "uuid",
14 | "primaryKey": true,
15 | "notNull": true,
16 | "default": "gen_random_uuid()"
17 | },
18 | "user_id": {
19 | "name": "user_id",
20 | "type": "uuid",
21 | "primaryKey": false,
22 | "notNull": true
23 | },
24 | "following_id": {
25 | "name": "following_id",
26 | "type": "uuid",
27 | "primaryKey": false,
28 | "notNull": true
29 | },
30 | "created_at": {
31 | "name": "created_at",
32 | "type": "timestamp",
33 | "primaryKey": false,
34 | "notNull": true,
35 | "default": "now()"
36 | }
37 | },
38 | "indexes": {
39 | "followers_user_id_idx": {
40 | "name": "followers_user_id_idx",
41 | "columns": [
42 | {
43 | "expression": "user_id",
44 | "isExpression": false,
45 | "asc": true,
46 | "nulls": "last"
47 | }
48 | ],
49 | "isUnique": false,
50 | "concurrently": false,
51 | "method": "btree",
52 | "with": {}
53 | },
54 | "followers_following_id_idx": {
55 | "name": "followers_following_id_idx",
56 | "columns": [
57 | {
58 | "expression": "following_id",
59 | "isExpression": false,
60 | "asc": true,
61 | "nulls": "last"
62 | }
63 | ],
64 | "isUnique": false,
65 | "concurrently": false,
66 | "method": "btree",
67 | "with": {}
68 | }
69 | },
70 | "foreignKeys": {},
71 | "compositePrimaryKeys": {},
72 | "uniqueConstraints": {},
73 | "policies": {},
74 | "checkConstraints": {},
75 | "isRLSEnabled": false
76 | },
77 | "public.notifications": {
78 | "name": "notifications",
79 | "schema": "",
80 | "columns": {
81 | "id": {
82 | "name": "id",
83 | "type": "uuid",
84 | "primaryKey": true,
85 | "notNull": true,
86 | "default": "gen_random_uuid()"
87 | },
88 | "user_id": {
89 | "name": "user_id",
90 | "type": "uuid",
91 | "primaryKey": false,
92 | "notNull": true
93 | },
94 | "from_user_id": {
95 | "name": "from_user_id",
96 | "type": "uuid",
97 | "primaryKey": false,
98 | "notNull": true
99 | },
100 | "type": {
101 | "name": "type",
102 | "type": "varchar(255)",
103 | "primaryKey": false,
104 | "notNull": true
105 | },
106 | "created_at": {
107 | "name": "created_at",
108 | "type": "timestamp",
109 | "primaryKey": false,
110 | "notNull": true,
111 | "default": "now()"
112 | },
113 | "content": {
114 | "name": "content",
115 | "type": "varchar(255)",
116 | "primaryKey": false,
117 | "notNull": false
118 | }
119 | },
120 | "indexes": {
121 | "notifications_user_id_idx": {
122 | "name": "notifications_user_id_idx",
123 | "columns": [
124 | {
125 | "expression": "user_id",
126 | "isExpression": false,
127 | "asc": true,
128 | "nulls": "last"
129 | }
130 | ],
131 | "isUnique": false,
132 | "concurrently": false,
133 | "method": "btree",
134 | "with": {}
135 | },
136 | "notifications_from_user_id_idx": {
137 | "name": "notifications_from_user_id_idx",
138 | "columns": [
139 | {
140 | "expression": "from_user_id",
141 | "isExpression": false,
142 | "asc": true,
143 | "nulls": "last"
144 | }
145 | ],
146 | "isUnique": false,
147 | "concurrently": false,
148 | "method": "btree",
149 | "with": {}
150 | }
151 | },
152 | "foreignKeys": {},
153 | "compositePrimaryKeys": {},
154 | "uniqueConstraints": {},
155 | "policies": {},
156 | "checkConstraints": {},
157 | "isRLSEnabled": false
158 | },
159 | "public.posts": {
160 | "name": "posts",
161 | "schema": "",
162 | "columns": {
163 | "id": {
164 | "name": "id",
165 | "type": "uuid",
166 | "primaryKey": true,
167 | "notNull": true,
168 | "default": "gen_random_uuid()"
169 | },
170 | "user_id": {
171 | "name": "user_id",
172 | "type": "uuid",
173 | "primaryKey": false,
174 | "notNull": true
175 | },
176 | "text": {
177 | "name": "text",
178 | "type": "varchar(255)",
179 | "primaryKey": false,
180 | "notNull": true
181 | },
182 | "created_at": {
183 | "name": "created_at",
184 | "type": "timestamp",
185 | "primaryKey": false,
186 | "notNull": true,
187 | "default": "now()"
188 | }
189 | },
190 | "indexes": {
191 | "posts_user_id_idx": {
192 | "name": "posts_user_id_idx",
193 | "columns": [
194 | {
195 | "expression": "user_id",
196 | "isExpression": false,
197 | "asc": true,
198 | "nulls": "last"
199 | }
200 | ],
201 | "isUnique": false,
202 | "concurrently": false,
203 | "method": "btree",
204 | "with": {}
205 | }
206 | },
207 | "foreignKeys": {},
208 | "compositePrimaryKeys": {},
209 | "uniqueConstraints": {},
210 | "policies": {},
211 | "checkConstraints": {},
212 | "isRLSEnabled": false
213 | },
214 | "public.profiles": {
215 | "name": "profiles",
216 | "schema": "",
217 | "columns": {
218 | "id": {
219 | "name": "id",
220 | "type": "uuid",
221 | "primaryKey": true,
222 | "notNull": true,
223 | "default": "gen_random_uuid()"
224 | },
225 | "user_id": {
226 | "name": "user_id",
227 | "type": "uuid",
228 | "primaryKey": false,
229 | "notNull": true
230 | },
231 | "display_name": {
232 | "name": "display_name",
233 | "type": "varchar(255)",
234 | "primaryKey": false,
235 | "notNull": true
236 | },
237 | "created_at": {
238 | "name": "created_at",
239 | "type": "timestamp",
240 | "primaryKey": false,
241 | "notNull": true,
242 | "default": "now()"
243 | }
244 | },
245 | "indexes": {
246 | "user_id_idx": {
247 | "name": "user_id_idx",
248 | "columns": [
249 | {
250 | "expression": "user_id",
251 | "isExpression": false,
252 | "asc": true,
253 | "nulls": "last"
254 | }
255 | ],
256 | "isUnique": false,
257 | "concurrently": false,
258 | "method": "btree",
259 | "with": {}
260 | }
261 | },
262 | "foreignKeys": {},
263 | "compositePrimaryKeys": {},
264 | "uniqueConstraints": {
265 | "profiles_user_id_unique": {
266 | "name": "profiles_user_id_unique",
267 | "nullsNotDistinct": false,
268 | "columns": [
269 | "user_id"
270 | ]
271 | }
272 | },
273 | "policies": {},
274 | "checkConstraints": {},
275 | "isRLSEnabled": false
276 | },
277 | "public.users": {
278 | "name": "users",
279 | "schema": "",
280 | "columns": {
281 | "id": {
282 | "name": "id",
283 | "type": "uuid",
284 | "primaryKey": true,
285 | "notNull": true,
286 | "default": "gen_random_uuid()"
287 | },
288 | "email": {
289 | "name": "email",
290 | "type": "varchar(255)",
291 | "primaryKey": false,
292 | "notNull": true
293 | },
294 | "password": {
295 | "name": "password",
296 | "type": "varchar(255)",
297 | "primaryKey": false,
298 | "notNull": true
299 | },
300 | "created_at": {
301 | "name": "created_at",
302 | "type": "timestamp",
303 | "primaryKey": false,
304 | "notNull": true,
305 | "default": "now()"
306 | }
307 | },
308 | "indexes": {},
309 | "foreignKeys": {},
310 | "compositePrimaryKeys": {},
311 | "uniqueConstraints": {
312 | "users_email_unique": {
313 | "name": "users_email_unique",
314 | "nullsNotDistinct": false,
315 | "columns": [
316 | "email"
317 | ]
318 | }
319 | },
320 | "policies": {},
321 | "checkConstraints": {},
322 | "isRLSEnabled": false
323 | }
324 | },
325 | "enums": {},
326 | "schemas": {},
327 | "sequences": {},
328 | "roles": {},
329 | "policies": {},
330 | "views": {},
331 | "_meta": {
332 | "columns": {},
333 | "schemas": {},
334 | "tables": {}
335 | }
336 | }
--------------------------------------------------------------------------------
/migrations/meta/0004_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "ab9aec2b-382b-4f6b-8276-dee9498b4cc3",
3 | "prevId": "c9df29e5-653b-46c6-a9a2-e86fbb334841",
4 | "version": "7",
5 | "dialect": "postgresql",
6 | "tables": {
7 | "public.followers": {
8 | "name": "followers",
9 | "schema": "",
10 | "columns": {
11 | "id": {
12 | "name": "id",
13 | "type": "uuid",
14 | "primaryKey": true,
15 | "notNull": true,
16 | "default": "gen_random_uuid()"
17 | },
18 | "user_id": {
19 | "name": "user_id",
20 | "type": "uuid",
21 | "primaryKey": false,
22 | "notNull": true
23 | },
24 | "following_id": {
25 | "name": "following_id",
26 | "type": "uuid",
27 | "primaryKey": false,
28 | "notNull": true
29 | },
30 | "created_at": {
31 | "name": "created_at",
32 | "type": "timestamp",
33 | "primaryKey": false,
34 | "notNull": true,
35 | "default": "now()"
36 | }
37 | },
38 | "indexes": {
39 | "followers_user_id_idx": {
40 | "name": "followers_user_id_idx",
41 | "columns": [
42 | {
43 | "expression": "user_id",
44 | "isExpression": false,
45 | "asc": true,
46 | "nulls": "last"
47 | }
48 | ],
49 | "isUnique": false,
50 | "concurrently": false,
51 | "method": "btree",
52 | "with": {}
53 | },
54 | "followers_following_id_idx": {
55 | "name": "followers_following_id_idx",
56 | "columns": [
57 | {
58 | "expression": "following_id",
59 | "isExpression": false,
60 | "asc": true,
61 | "nulls": "last"
62 | }
63 | ],
64 | "isUnique": false,
65 | "concurrently": false,
66 | "method": "btree",
67 | "with": {}
68 | }
69 | },
70 | "foreignKeys": {},
71 | "compositePrimaryKeys": {},
72 | "uniqueConstraints": {},
73 | "policies": {},
74 | "checkConstraints": {},
75 | "isRLSEnabled": false
76 | },
77 | "public.notifications": {
78 | "name": "notifications",
79 | "schema": "",
80 | "columns": {
81 | "id": {
82 | "name": "id",
83 | "type": "uuid",
84 | "primaryKey": true,
85 | "notNull": true,
86 | "default": "gen_random_uuid()"
87 | },
88 | "user_id": {
89 | "name": "user_id",
90 | "type": "uuid",
91 | "primaryKey": false,
92 | "notNull": true
93 | },
94 | "from_user_id": {
95 | "name": "from_user_id",
96 | "type": "uuid",
97 | "primaryKey": false,
98 | "notNull": true
99 | },
100 | "type": {
101 | "name": "type",
102 | "type": "varchar(255)",
103 | "primaryKey": false,
104 | "notNull": true
105 | },
106 | "created_at": {
107 | "name": "created_at",
108 | "type": "timestamp",
109 | "primaryKey": false,
110 | "notNull": true,
111 | "default": "now()"
112 | },
113 | "content": {
114 | "name": "content",
115 | "type": "varchar(255)",
116 | "primaryKey": false,
117 | "notNull": false
118 | }
119 | },
120 | "indexes": {
121 | "notifications_user_id_idx": {
122 | "name": "notifications_user_id_idx",
123 | "columns": [
124 | {
125 | "expression": "user_id",
126 | "isExpression": false,
127 | "asc": true,
128 | "nulls": "last"
129 | }
130 | ],
131 | "isUnique": false,
132 | "concurrently": false,
133 | "method": "btree",
134 | "with": {}
135 | },
136 | "notifications_from_user_id_idx": {
137 | "name": "notifications_from_user_id_idx",
138 | "columns": [
139 | {
140 | "expression": "from_user_id",
141 | "isExpression": false,
142 | "asc": true,
143 | "nulls": "last"
144 | }
145 | ],
146 | "isUnique": false,
147 | "concurrently": false,
148 | "method": "btree",
149 | "with": {}
150 | }
151 | },
152 | "foreignKeys": {},
153 | "compositePrimaryKeys": {},
154 | "uniqueConstraints": {},
155 | "policies": {},
156 | "checkConstraints": {},
157 | "isRLSEnabled": false
158 | },
159 | "public.posts": {
160 | "name": "posts",
161 | "schema": "",
162 | "columns": {
163 | "id": {
164 | "name": "id",
165 | "type": "uuid",
166 | "primaryKey": true,
167 | "notNull": true,
168 | "default": "gen_random_uuid()"
169 | },
170 | "user_id": {
171 | "name": "user_id",
172 | "type": "uuid",
173 | "primaryKey": false,
174 | "notNull": true
175 | },
176 | "text": {
177 | "name": "text",
178 | "type": "varchar(255)",
179 | "primaryKey": false,
180 | "notNull": true
181 | },
182 | "created_at": {
183 | "name": "created_at",
184 | "type": "timestamp",
185 | "primaryKey": false,
186 | "notNull": true,
187 | "default": "now()"
188 | }
189 | },
190 | "indexes": {
191 | "posts_user_id_idx": {
192 | "name": "posts_user_id_idx",
193 | "columns": [
194 | {
195 | "expression": "user_id",
196 | "isExpression": false,
197 | "asc": true,
198 | "nulls": "last"
199 | }
200 | ],
201 | "isUnique": false,
202 | "concurrently": false,
203 | "method": "btree",
204 | "with": {}
205 | }
206 | },
207 | "foreignKeys": {},
208 | "compositePrimaryKeys": {},
209 | "uniqueConstraints": {},
210 | "policies": {},
211 | "checkConstraints": {},
212 | "isRLSEnabled": false
213 | },
214 | "public.profiles": {
215 | "name": "profiles",
216 | "schema": "",
217 | "columns": {
218 | "id": {
219 | "name": "id",
220 | "type": "uuid",
221 | "primaryKey": true,
222 | "notNull": true,
223 | "default": "gen_random_uuid()"
224 | },
225 | "user_id": {
226 | "name": "user_id",
227 | "type": "uuid",
228 | "primaryKey": false,
229 | "notNull": true
230 | },
231 | "display_name": {
232 | "name": "display_name",
233 | "type": "varchar(255)",
234 | "primaryKey": false,
235 | "notNull": true
236 | },
237 | "created_at": {
238 | "name": "created_at",
239 | "type": "timestamp",
240 | "primaryKey": false,
241 | "notNull": true,
242 | "default": "now()"
243 | },
244 | "image_id": {
245 | "name": "image_id",
246 | "type": "uuid",
247 | "primaryKey": false,
248 | "notNull": false
249 | }
250 | },
251 | "indexes": {
252 | "user_id_idx": {
253 | "name": "user_id_idx",
254 | "columns": [
255 | {
256 | "expression": "user_id",
257 | "isExpression": false,
258 | "asc": true,
259 | "nulls": "last"
260 | }
261 | ],
262 | "isUnique": false,
263 | "concurrently": false,
264 | "method": "btree",
265 | "with": {}
266 | }
267 | },
268 | "foreignKeys": {},
269 | "compositePrimaryKeys": {},
270 | "uniqueConstraints": {
271 | "profiles_user_id_unique": {
272 | "name": "profiles_user_id_unique",
273 | "nullsNotDistinct": false,
274 | "columns": [
275 | "user_id"
276 | ]
277 | }
278 | },
279 | "policies": {},
280 | "checkConstraints": {},
281 | "isRLSEnabled": false
282 | },
283 | "public.users": {
284 | "name": "users",
285 | "schema": "",
286 | "columns": {
287 | "id": {
288 | "name": "id",
289 | "type": "uuid",
290 | "primaryKey": true,
291 | "notNull": true,
292 | "default": "gen_random_uuid()"
293 | },
294 | "email": {
295 | "name": "email",
296 | "type": "varchar(255)",
297 | "primaryKey": false,
298 | "notNull": true
299 | },
300 | "password": {
301 | "name": "password",
302 | "type": "varchar(255)",
303 | "primaryKey": false,
304 | "notNull": true
305 | },
306 | "created_at": {
307 | "name": "created_at",
308 | "type": "timestamp",
309 | "primaryKey": false,
310 | "notNull": true,
311 | "default": "now()"
312 | }
313 | },
314 | "indexes": {},
315 | "foreignKeys": {},
316 | "compositePrimaryKeys": {},
317 | "uniqueConstraints": {
318 | "users_email_unique": {
319 | "name": "users_email_unique",
320 | "nullsNotDistinct": false,
321 | "columns": [
322 | "email"
323 | ]
324 | }
325 | },
326 | "policies": {},
327 | "checkConstraints": {},
328 | "isRLSEnabled": false
329 | }
330 | },
331 | "enums": {},
332 | "schemas": {},
333 | "sequences": {},
334 | "roles": {},
335 | "policies": {},
336 | "views": {},
337 | "_meta": {
338 | "columns": {},
339 | "schemas": {},
340 | "tables": {}
341 | }
342 | }
--------------------------------------------------------------------------------
/migrations/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "postgresql",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "7",
8 | "when": 1741539904523,
9 | "tag": "0000_soft_electro",
10 | "breakpoints": true
11 | },
12 | {
13 | "idx": 1,
14 | "version": "7",
15 | "when": 1741648574603,
16 | "tag": "0001_remarkable_argent",
17 | "breakpoints": true
18 | },
19 | {
20 | "idx": 2,
21 | "version": "7",
22 | "when": 1741652140030,
23 | "tag": "0002_familiar_rogue",
24 | "breakpoints": true
25 | },
26 | {
27 | "idx": 3,
28 | "version": "7",
29 | "when": 1741652226439,
30 | "tag": "0003_flat_lord_tyger",
31 | "breakpoints": true
32 | },
33 | {
34 | "idx": 4,
35 | "version": "7",
36 | "when": 1741655538488,
37 | "tag": "0004_flowery_young_avengers",
38 | "breakpoints": true
39 | }
40 | ]
41 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "get-social",
3 | "main": "expo-router/entry",
4 | "version": "1.0.0",
5 | "scripts": {
6 | "start": "expo start",
7 | "android": "expo run:android",
8 | "ios": "expo run:ios",
9 | "web": "expo start --web",
10 | "lint": "expo lint",
11 | "db:generate": "drizzle-kit generate",
12 | "db:migrate": "drizzle-kit migrate",
13 | "db:studio": "drizzle-kit studio"
14 | },
15 | "dependencies": {
16 | "@aws-sdk/client-s3": "^3.758.0",
17 | "@aws-sdk/s3-presigned-post": "^3.758.0",
18 | "@babel/plugin-transform-class-static-block": "^7.26.0",
19 | "@bacons/apple-colors": "^0.0.8",
20 | "@expo/vector-icons": "^14.0.2",
21 | "@react-native-masked-view/masked-view": "0.3.2",
22 | "@react-native-segmented-control/segmented-control": "2.5.4",
23 | "@react-navigation/bottom-tabs": "^7.2.0",
24 | "@react-navigation/native": "^7.0.14",
25 | "@tanstack/react-query": "^5.67.2",
26 | "@types/jsonwebtoken": "^9.0.9",
27 | "date-fns": "^4.1.0",
28 | "dotenv": "^16.4.7",
29 | "drizzle-kit": "^0.30.5",
30 | "drizzle-orm": "^0.40.0",
31 | "expo": "~52.0.28",
32 | "expo-blur": "~14.0.3",
33 | "expo-constants": "~17.0.5",
34 | "expo-font": "~13.0.3",
35 | "expo-haptics": "~14.0.1",
36 | "expo-image-picker": "^16.0.6",
37 | "expo-linking": "~7.0.5",
38 | "expo-router": "~4.0.17",
39 | "expo-secure-store": "^14.0.1",
40 | "expo-splash-screen": "~0.29.21",
41 | "expo-sqlite": "~15.1.1",
42 | "expo-status-bar": "~2.0.1",
43 | "expo-symbols": "~0.2.2",
44 | "expo-system-ui": "~4.0.7",
45 | "expo-web-browser": "~14.0.2",
46 | "jsonwebtoken": "^9.0.2",
47 | "postgres": "^3.4.5",
48 | "react": "18.3.1",
49 | "react-dom": "18.3.1",
50 | "react-native": "0.76.6",
51 | "react-native-gesture-handler": "~2.20.2",
52 | "react-native-reanimated": "~3.16.1",
53 | "react-native-safe-area-context": "4.12.0",
54 | "react-native-screens": "~4.4.0",
55 | "react-native-web": "~0.19.13",
56 | "react-native-webview": "13.12.5",
57 | "s3rver": "^3.7.1",
58 | "unique-username-generator": "^1.4.0",
59 | "uuid": "^11.1.0",
60 | "vaul": "^1.1.2"
61 | },
62 | "devDependencies": {
63 | "@babel/core": "^7.25.2",
64 | "@types/jest": "^29.5.12",
65 | "@types/react": "~18.3.12",
66 | "@types/react-test-renderer": "^18.3.0",
67 | "eslint": "^8.57.0",
68 | "eslint-config-expo": "~8.0.1",
69 | "jest": "^29.2.1",
70 | "jest-expo": "~52.0.3",
71 | "react-test-renderer": "18.3.1",
72 | "typescript": "^5.3.3"
73 | },
74 | "private": true
75 | }
76 |
--------------------------------------------------------------------------------
/s3.mjs:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import S3rver from "s3rver";
3 |
4 | new S3rver({
5 | port: 9000,
6 | address: "0.0.0.0",
7 | directory: "./s3",
8 | configureBuckets: [
9 | {
10 | name: "get-social",
11 | configs: [fs.readFileSync("./cors.xml")],
12 | },
13 | ],
14 | }).run();
15 |
--------------------------------------------------------------------------------
/s3/error.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Document
8 |
9 |
10 |
--------------------------------------------------------------------------------
/s3/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Document
8 |
9 |
10 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/utils/auth.ts:
--------------------------------------------------------------------------------
1 | import jwt from "jsonwebtoken";
2 |
3 | export async function hashPassword(password: string) {
4 | const encoder = new TextEncoder();
5 | const passwordBuffer = encoder.encode(password);
6 | const hashBuffer = await crypto.subtle.digest("SHA-256", passwordBuffer);
7 | const hashedPassword = Array.from(new Uint8Array(hashBuffer))
8 | .map((b) => b.toString(16).padStart(2, "0"))
9 | .join("");
10 |
11 | return hashedPassword;
12 | }
13 |
14 | export async function generateJwt(userId: string) {
15 | return jwt.sign({ userId }, process.env.JWT_SECRET!);
16 | }
17 |
--------------------------------------------------------------------------------
/utils/images.ts:
--------------------------------------------------------------------------------
1 | export function getImageUrl(imageId: string) {
2 | return `${process.env.EXPO_PUBLIC_STORAGE_BUCKET_NAME}/${imageId}`;
3 | }
4 |
--------------------------------------------------------------------------------
/utils/storage.ts:
--------------------------------------------------------------------------------
1 | import * as SecureStore from "expo-secure-store";
2 |
3 | export async function secureSave(key: string, value: string) {
4 | if (process.env.EXPO_OS === "web") {
5 | localStorage.setItem(key, value);
6 | } else {
7 | await SecureStore.setItemAsync(key, value);
8 | }
9 | }
10 |
11 | export async function secureGet(key: string) {
12 | if (process.env.EXPO_OS === "web") {
13 | return localStorage.getItem(key);
14 | } else {
15 | return await SecureStore.getItemAsync(key);
16 | }
17 | }
18 |
19 | export async function secureDelete(key: string) {
20 | if (process.env.EXPO_OS === "web") {
21 | localStorage.removeItem(key);
22 | } else {
23 | await SecureStore.deleteItemAsync(key);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/utils/withAuth.ts:
--------------------------------------------------------------------------------
1 | import jwt from "jsonwebtoken";
2 | import { db } from "@/db";
3 | import { eq } from "drizzle-orm";
4 | import { User, users } from "@/db/schema";
5 |
6 | async function getUser(request: Request) {
7 | const token = request.headers.get("authorization")?.split(" ")[1];
8 |
9 | if (!token) {
10 | return undefined;
11 | }
12 |
13 | try {
14 | const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
15 | userId: string;
16 | };
17 |
18 | const user = await db.query.users.findFirst({
19 | where: eq(users.id, decoded.userId),
20 | });
21 |
22 | return user;
23 | } catch (err) {
24 | console.error(err);
25 | return undefined;
26 | }
27 | }
28 |
29 | export function withAuth(
30 | handler: (request: Request, user: User) => Promise
31 | ) {
32 | return async (request: Request) => {
33 | const user = await getUser(request);
34 |
35 | if (!user) {
36 | return Response.json({ error: "Unauthorized" }, { status: 401 });
37 | }
38 |
39 | return handler(request, user);
40 | };
41 | }
42 |
--------------------------------------------------------------------------------