├── bun.lockb
├── assets
├── images
│ ├── icon.png
│ ├── logo.png
│ ├── favicon.png
│ ├── google-icon.png
│ ├── react-logo.png
│ ├── splash-icon.png
│ ├── adaptive-icon.png
│ ├── react-logo@2x.png
│ ├── react-logo@3x.png
│ └── partial-react-logo.png
└── fonts
│ └── SpaceMono-Regular.ttf
├── .env.local.example
├── colors.ts
├── tsconfig.json
├── components
├── Text.tsx
├── Input.tsx
├── Button.tsx
├── IconSymbol.ios.tsx
└── IconSymbol.tsx
├── app
├── (auth)
│ ├── _layout.tsx
│ └── index.tsx
├── _layout.tsx
└── (chat)
│ ├── settings
│ └── [chat].tsx
│ ├── _layout.tsx
│ ├── new-room.tsx
│ ├── profile.tsx
│ ├── index.tsx
│ └── [chat].tsx
├── utils
├── test-data.ts
├── types.ts
├── appwrite.ts
└── cache.ts
├── .gitignore
├── LICENSE
├── app.json
├── package.json
└── README.md
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/modern-chat-app/HEAD/bun.lockb
--------------------------------------------------------------------------------
/assets/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/modern-chat-app/HEAD/assets/images/icon.png
--------------------------------------------------------------------------------
/assets/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/modern-chat-app/HEAD/assets/images/logo.png
--------------------------------------------------------------------------------
/assets/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/modern-chat-app/HEAD/assets/images/favicon.png
--------------------------------------------------------------------------------
/assets/images/google-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/modern-chat-app/HEAD/assets/images/google-icon.png
--------------------------------------------------------------------------------
/assets/images/react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/modern-chat-app/HEAD/assets/images/react-logo.png
--------------------------------------------------------------------------------
/assets/images/splash-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/modern-chat-app/HEAD/assets/images/splash-icon.png
--------------------------------------------------------------------------------
/assets/images/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/modern-chat-app/HEAD/assets/images/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/images/react-logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/modern-chat-app/HEAD/assets/images/react-logo@2x.png
--------------------------------------------------------------------------------
/assets/images/react-logo@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/modern-chat-app/HEAD/assets/images/react-logo@3x.png
--------------------------------------------------------------------------------
/.env.local.example:
--------------------------------------------------------------------------------
1 | EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=your-clerk-publishable-key
2 | EXPO_PUBLIC_APPWRITE_APP_ID=your-appwrite-app-id
--------------------------------------------------------------------------------
/assets/fonts/SpaceMono-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/modern-chat-app/HEAD/assets/fonts/SpaceMono-Regular.ttf
--------------------------------------------------------------------------------
/assets/images/partial-react-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/betomoedano/modern-chat-app/HEAD/assets/images/partial-react-logo.png
--------------------------------------------------------------------------------
/colors.ts:
--------------------------------------------------------------------------------
1 | export const Primary = "#007AFF";
2 | export const Secondary = "#262626";
3 | export const Red = "#FF3B30";
4 | export const RedSecondary = "#441320";
5 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/components/Text.tsx:
--------------------------------------------------------------------------------
1 | import { Text as RNText, TextProps } from "react-native";
2 | import React from "react";
3 |
4 | interface CustomTextProps extends TextProps {
5 | children: React.ReactNode;
6 | }
7 |
8 | export function Text({ children, style, ...props }: CustomTextProps) {
9 | return (
10 |
11 | {children}
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/app/(auth)/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { useUser } from "@clerk/clerk-expo";
2 | import { Redirect, Stack } from "expo-router";
3 |
4 | export default function RootChatLayout() {
5 | const { isSignedIn } = useUser();
6 |
7 | if (isSignedIn) {
8 | return ;
9 | }
10 |
11 | return (
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/utils/test-data.ts:
--------------------------------------------------------------------------------
1 | import { ChatRoom } from "./types";
2 |
3 | export const chatRooms: ChatRoom[] = [
4 | {
5 | id: "1",
6 | title: "Chat Room 1",
7 | description: "Chat Room 1 Description",
8 | isPrivate: false,
9 | createdAt: new Date(),
10 | updatedAt: new Date(),
11 | },
12 | {
13 | id: "2",
14 | title: "Chat Room 2",
15 | description: "Chat Room 2 Description",
16 | isPrivate: true,
17 | createdAt: new Date(),
18 | updatedAt: new Date(),
19 | },
20 | ];
21 |
--------------------------------------------------------------------------------
/components/Input.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet, TextInput, TextInputProps } from "react-native";
2 |
3 | export default function Input(props: TextInputProps) {
4 | const { style, ...rest } = props;
5 | return (
6 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/.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 | /ios
21 | /android
22 |
23 | # Metro
24 | .metro-health-check*
25 |
26 | # debug
27 | npm-debug.*
28 | yarn-debug.*
29 | yarn-error.*
30 |
31 | # macOS
32 | .DS_Store
33 | *.pem
34 |
35 | # local env files
36 | .env*.local
37 |
38 | # typescript
39 | *.tsbuildinfo
40 |
41 | app-example
42 |
--------------------------------------------------------------------------------
/utils/types.ts:
--------------------------------------------------------------------------------
1 | interface ChatRoom {
2 | id: string;
3 | title: string;
4 | description: string;
5 | isPrivate: boolean;
6 | createdAt: Date;
7 | updatedAt: Date;
8 | }
9 | interface Message {
10 | $id?: string;
11 | $createdAt?: string;
12 | $updatedAt?: string;
13 | $collectionId?: string;
14 | $databaseId?: string;
15 | $permissions?: any[];
16 | content: string;
17 | senderId: string;
18 | senderName: string;
19 | senderPhoto: string;
20 | chatRoomId: string;
21 | }
22 |
23 | interface User {
24 | id: string;
25 | fullName: string;
26 | email: string;
27 | imageUrl: string;
28 | }
29 |
30 | export type { ChatRoom, Message, User };
31 |
--------------------------------------------------------------------------------
/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import { Pressable, PressableProps, Text, ViewStyle } from "react-native";
2 |
3 | export function Button({ children, style, ...props }: PressableProps) {
4 | return (
5 |
17 | {typeof children === "string" ? (
18 |
19 | {children}
20 |
21 | ) : (
22 | children
23 | )}
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/components/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 | }: {
11 | name: SymbolViewProps["name"];
12 | size?: number;
13 | color: string;
14 | style?: StyleProp;
15 | weight?: SymbolWeight;
16 | }) {
17 | return (
18 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/utils/appwrite.ts:
--------------------------------------------------------------------------------
1 | import { Client, Databases } from "react-native-appwrite";
2 |
3 | if (!process.env.EXPO_PUBLIC_APPWRITE_APP_ID) {
4 | throw new Error("EXPO_PUBLIC_APPWRITE_APP_ID is not set");
5 | }
6 |
7 | /**
8 | * Create a db in appwrite and add your collections
9 | */
10 | const appwriteConfig = {
11 | endpoint: "https://cloud.appwrite.io/v1",
12 | projectId: process.env.EXPO_PUBLIC_APPWRITE_APP_ID,
13 | platform: "com.betoatexpo.modern-chat-app",
14 | db: "67d59a3300219b4fc01a",
15 | col: {
16 | chatRooms: "67d59bbe000376c4cbe8",
17 | message: "67d59beb0003e12f398b",
18 | user: "67d59bd40026f76926fd",
19 | },
20 | };
21 |
22 | const client = new Client()
23 | .setEndpoint(appwriteConfig.endpoint)
24 | .setProject(appwriteConfig.projectId)
25 | .setPlatform(appwriteConfig.platform);
26 |
27 | const database = new Databases(client);
28 | export { database, appwriteConfig, client };
29 |
--------------------------------------------------------------------------------
/app/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { ClerkLoaded, ClerkProvider } from "@clerk/clerk-expo";
2 | import { DarkTheme, ThemeProvider } from "@react-navigation/native";
3 | import { Slot } from "expo-router";
4 | import { tokenCache } from "@/utils/cache";
5 | import { StatusBar } from "react-native";
6 | import { passkeys } from "@clerk/expo-passkeys";
7 |
8 | export default function RootLayout() {
9 | const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!;
10 |
11 | if (!publishableKey) {
12 | throw new Error("Add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to your .env file");
13 | }
14 |
15 | return (
16 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/utils/cache.ts:
--------------------------------------------------------------------------------
1 | import * as SecureStore from "expo-secure-store";
2 | import { Platform } from "react-native";
3 | import { TokenCache } from "@clerk/clerk-expo/dist/cache";
4 |
5 | const createTokenCache = (): TokenCache => {
6 | return {
7 | getToken: async (key: string) => {
8 | try {
9 | const item = await SecureStore.getItemAsync(key);
10 | if (item) {
11 | console.log(`${key} was used 🔐 \n`);
12 | } else {
13 | console.log("No values stored under key: " + key);
14 | }
15 | return item;
16 | } catch (error) {
17 | console.error("secure store get item error: ", error);
18 | await SecureStore.deleteItemAsync(key);
19 | return null;
20 | }
21 | },
22 | saveToken: (key: string, token: string) => {
23 | return SecureStore.setItemAsync(key, token);
24 | },
25 | };
26 | };
27 |
28 | // SecureStore is not supported on the web
29 | export const tokenCache =
30 | Platform.OS !== "web" ? createTokenCache() : undefined;
31 |
--------------------------------------------------------------------------------
/app/(chat)/settings/[chat].tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/Button";
2 | import { Text } from "@/components/Text";
3 | import { appwriteConfig, database } from "@/utils/appwrite";
4 | import { useLocalSearchParams, useRouter } from "expo-router";
5 | import { View } from "react-native";
6 |
7 | export default function ChatSettings() {
8 | const { chat: chatRoomId } = useLocalSearchParams();
9 | const router = useRouter();
10 |
11 | if (!chatRoomId) {
12 | return We couldn't find this chat room 🥲;
13 | }
14 |
15 | async function handleDeleteChat() {
16 | try {
17 | await database.deleteDocument(
18 | appwriteConfig.db,
19 | appwriteConfig.col.chatRooms,
20 | chatRoomId as string
21 | );
22 | router.replace("/(chat)");
23 | } catch (error) {
24 | console.error(error);
25 | }
26 | }
27 |
28 | return (
29 |
30 | Chat Settings
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Code with Beto LLC
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 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "modern-chat-app",
4 | "slug": "modern-chat-app",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/images/icon.png",
8 | "scheme": "myapp",
9 | "userInterfaceStyle": "dark",
10 | "newArchEnabled": true,
11 | "ios": {
12 | "supportsTablet": true,
13 | "bundleIdentifier": "com.betoatexpo.modern-chat-app",
14 | "associatedDomains": [
15 | "applinks:quiet-bulldog-8.clerk.accounts.dev",
16 | "webcredentials:quiet-bulldog-8.clerk.accounts.dev"
17 | ]
18 | },
19 | "android": {
20 | "adaptiveIcon": {
21 | "foregroundImage": "./assets/images/adaptive-icon.png",
22 | "backgroundColor": "#ffffff"
23 | },
24 | "package": "com.betoatexpo.modernchatapp"
25 | },
26 | "web": {
27 | "bundler": "metro",
28 | "output": "static",
29 | "favicon": "./assets/images/favicon.png"
30 | },
31 | "plugins": [
32 | "expo-router",
33 | [
34 | "expo-splash-screen",
35 | {
36 | "image": "./assets/images/splash-icon.png",
37 | "imageWidth": 200,
38 | "resizeMode": "contain",
39 | "backgroundColor": "#ffffff"
40 | }
41 | ],
42 | "expo-secure-store",
43 | [
44 | "expo-build-properties",
45 | {
46 | "ios": {
47 | "deploymentTarget": "16.0"
48 | }
49 | }
50 | ]
51 | ],
52 | "experiments": {
53 | "typedRoutes": true
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/app/(chat)/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { Link, Stack } from "expo-router";
2 | import { IconSymbol } from "@/components/IconSymbol";
3 | import { Image } from "react-native";
4 | import { useUser } from "@clerk/clerk-expo";
5 |
6 | export default function RootChatLayout() {
7 | const { user } = useUser();
8 |
9 | return (
10 |
11 | (
17 |
18 |
22 |
23 | ),
24 | headerRight: () => (
25 |
26 |
27 |
28 | ),
29 | }}
30 | />
31 | (
37 |
38 |
39 |
40 | ),
41 | }}
42 | />
43 |
44 |
45 | {/* Set title to empty string to prevent showing [chat] in the header while chat room title is being fetched */}
46 |
47 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/components/IconSymbol.tsx:
--------------------------------------------------------------------------------
1 | // This file is a fallback for using MaterialIcons on Android and web.
2 |
3 | import MaterialIcons from "@expo/vector-icons/MaterialIcons";
4 | import { SymbolWeight } from "expo-symbols";
5 | import React from "react";
6 | import {
7 | OpaqueColorValue,
8 | StyleProp,
9 | ViewStyle,
10 | TextStyle,
11 | } from "react-native";
12 |
13 | // Add your SFSymbol to MaterialIcons mappings here.
14 | const MAPPING = {
15 | // See MaterialIcons here: https://icons.expo.fyi
16 | // See SF Symbols in the SF Symbols app on Mac.
17 | "house.fill": "home",
18 | "paperplane.fill": "send",
19 | "chevron.left.forwardslash.chevron.right": "code",
20 | "chevron.right": "chevron-right",
21 | gearshape: "settings",
22 | plus: "add",
23 | paperplane: "send",
24 | } as Partial<
25 | Record<
26 | import("expo-symbols").SymbolViewProps["name"],
27 | React.ComponentProps["name"]
28 | >
29 | >;
30 |
31 | export type IconSymbolName = keyof typeof MAPPING;
32 |
33 | /**
34 | * An icon component that uses native SFSymbols on iOS, and MaterialIcons on Android and web. This ensures a consistent look across platforms, and optimal resource usage.
35 | *
36 | * Icon `name`s are based on SFSymbols and require manual mapping to MaterialIcons.
37 | */
38 | export function IconSymbol({
39 | name,
40 | size = 24,
41 | color = "#007AFF",
42 | style,
43 | weight = "regular",
44 | }: {
45 | name: IconSymbolName;
46 | size?: number;
47 | color?: string | OpaqueColorValue;
48 | style?: StyleProp;
49 | weight?: SymbolWeight;
50 | }) {
51 | return (
52 | }
57 | />
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/app/(chat)/new-room.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { View, Switch, Button } from "react-native";
3 | import Input from "@/components/Input";
4 | import { useState } from "react";
5 | import { Text } from "@/components/Text";
6 | import { Stack, router } from "expo-router";
7 | import { Secondary } from "@/colors";
8 | import { appwriteConfig, database } from "@/utils/appwrite";
9 | import { ID } from "react-native-appwrite";
10 |
11 | export default function NewRoom() {
12 | const [roomName, setRoomName] = useState("");
13 | const [roomDescription, setRoomDescription] = useState("");
14 | const [isLoading, setIsLoading] = useState(false);
15 | async function createRoom() {
16 | try {
17 | setIsLoading(true);
18 | const room = await database.createDocument(
19 | appwriteConfig.db,
20 | appwriteConfig.col.chatRooms,
21 | ID.unique(),
22 | {
23 | title: roomName,
24 | description: roomDescription,
25 | }
26 | );
27 | } catch (error) {
28 | console.error(error);
29 | } finally {
30 | setIsLoading(false);
31 | router.back();
32 | }
33 | }
34 | return (
35 | <>
36 | (
39 |
44 | ),
45 | }}
46 | />
47 |
48 |
53 |
62 |
63 | >
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "modern-chat-app",
3 | "main": "expo-router/entry",
4 | "version": "1.0.0",
5 | "scripts": {
6 | "start": "expo start",
7 | "reset-project": "node ./scripts/reset-project.js",
8 | "android": "expo run:android",
9 | "ios": "expo run:ios",
10 | "web": "expo start --web",
11 | "test": "jest --watchAll",
12 | "lint": "expo lint"
13 | },
14 | "jest": {
15 | "preset": "jest-expo"
16 | },
17 | "dependencies": {
18 | "@clerk/clerk-expo": "^2.8.5",
19 | "@clerk/expo-passkeys": "0.2.0-snapshot.v20250320015558",
20 | "@clerk/types": "^4.48.0",
21 | "@expo/vector-icons": "^14.0.2",
22 | "@legendapp/list": "^1.0.0-beta.21",
23 | "@react-navigation/bottom-tabs": "^7.2.0",
24 | "@react-navigation/native": "^7.0.14",
25 | "expo": "~52.0.38",
26 | "expo-auth-session": "~6.0.3",
27 | "expo-blur": "~14.0.3",
28 | "expo-build-properties": "~0.13.2",
29 | "expo-constants": "~17.0.8",
30 | "expo-crypto": "~14.0.2",
31 | "expo-font": "~13.0.4",
32 | "expo-haptics": "~14.0.1",
33 | "expo-linking": "~7.0.5",
34 | "expo-router": "~4.0.18",
35 | "expo-secure-store": "~14.0.1",
36 | "expo-splash-screen": "~0.29.22",
37 | "expo-status-bar": "~2.0.1",
38 | "expo-symbols": "~0.2.2",
39 | "expo-system-ui": "~4.0.8",
40 | "expo-web-browser": "~14.0.2",
41 | "react": "18.3.1",
42 | "react-dom": "18.3.1",
43 | "react-native": "0.76.7",
44 | "react-native-appwrite": "0.7.0",
45 | "react-native-gesture-handler": "~2.20.2",
46 | "react-native-reanimated": "~3.16.1",
47 | "react-native-safe-area-context": "4.12.0",
48 | "react-native-screens": "~4.4.0",
49 | "react-native-url-polyfill": "^2.0.0",
50 | "react-native-web": "~0.19.13",
51 | "react-native-webview": "13.12.5"
52 | },
53 | "devDependencies": {
54 | "@babel/core": "^7.25.2",
55 | "@types/jest": "^29.5.12",
56 | "@types/react": "~18.3.12",
57 | "@types/react-test-renderer": "^18.3.0",
58 | "jest": "^29.2.1",
59 | "jest-expo": "~52.0.6",
60 | "react-test-renderer": "18.3.1",
61 | "typescript": "^5.3.3"
62 | },
63 | "private": true
64 | }
65 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://codewithbeto.dev/projects/modern-chat-app)
2 |
3 | # Modern Chat App
4 |
5 | A real-time multi-user chat application built with React Native and Expo, featuring seamless authentication, modern UI, and real-time updates. Built with Clerk for Passkeys & Google Sign-In and Appwrite for the backend.
6 |
7 | 🔍 Explore more innovative projects and tutorials at [codewithbeto.dev/projects](https://codewithbeto.dev/projects)
8 |
9 | ## 🚀 Video, Demo & Links
10 |
11 |
12 |
13 |
14 |
15 | - 📱 [GitHub Repository](https://github.com/betomoedano/modern-chat-app)
16 | - 💻 [Project Details](https://codewithbeto.dev/projects/modern-chat-app)
17 | - 📺 [Video Tutorial](https://youtu.be/HKJdqJIDtMs)
18 | - 🎨 [Figma Design](https://www.figma.com/community/file/1483864984697101015/chat-room-app)
19 |
20 | ## ⚡ Tech Stack
21 |
22 | - [Expo](https://expo.dev/) - React Native framework
23 | - [Clerk](https://clerk.dev/) - Authentication & user management
24 | - [Appwrite](https://appwrite.io/) - Backend & real-time database
25 | - [@legendapp/list](https://www.npmjs.com/package/@legendapp/list) - High-performance list components
26 |
27 | ## 🛠️ Setup & Installation
28 |
29 | ### Prerequisites
30 |
31 | - [Clerk Account](https://go.clerk.com/Wt70O5j)
32 | - [Appwrite Account](https://appwrite.io/)
33 | - Apple Team ID (for passkeys on iOS)
34 |
35 | ### Installation
36 |
37 | 1. Clone the repository:
38 |
39 | ```bash
40 | git clone https://github.com/betomoedano/modern-chat-app.git
41 | cd modern-chat-app
42 | ```
43 |
44 | 2. Install dependencies:
45 |
46 | ```bash
47 | bun install
48 | ```
49 |
50 | 3. Configure environment variables:
51 |
52 | Create a `.env.local` file in the root directory with:
53 |
54 | ```bash
55 | EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=your-key-here
56 | EXPO_PUBLIC_APPWRITE_APP_ID=your-app-write-app-id
57 | ```
58 |
59 | 4. Update the `app.json` with your cleerk front end api
60 |
61 | 5. Start the development server:
62 |
63 | ```bash
64 | npx expo start
65 | ```
66 |
67 | ## 📱 Features
68 |
69 | - 🔐 Secure authentication with Clerk (Passkeys & Google Sign-In)
70 | - 💬 Real-time chat functionality
71 | - 🎨 Modern UI
72 | - 📱 Cross-platform compatibility
73 | - 🎯 TypeScript for type safety
74 |
75 | ## 🎓 Learning Resources
76 |
77 | Want to learn more about React Native development? Check out:
78 |
79 | - [React Native Course](https://codewithbeto.dev/learn)
80 | - [React with TypeScript Course](https://codewithbeto.dev/learnReact)
81 |
82 | ## 📄 License
83 |
84 | This project is open source and available under the MIT License.
85 |
--------------------------------------------------------------------------------
/app/(chat)/profile.tsx:
--------------------------------------------------------------------------------
1 | import { View, Image, TouchableOpacity } from "react-native";
2 | import { Text } from "@/components/Text";
3 | import { useAuth, useUser } from "@clerk/clerk-expo";
4 | import { useRouter } from "expo-router";
5 | import { Button } from "@/components/Button";
6 |
7 | export default function Profile() {
8 | const { signOut } = useAuth();
9 | const { user } = useUser();
10 | const router = useRouter();
11 | const passkeys = user?.passkeys ?? [];
12 |
13 | const handleSignOut = async () => {
14 | await signOut();
15 | router.replace("/(auth)");
16 | };
17 | return (
18 |
19 |
23 |
24 |
25 | {user?.fullName}
26 |
27 |
28 | {user?.emailAddresses[0].emailAddress}
29 |
30 |
31 |
32 |
33 |
34 | Passkeys
35 | {passkeys.length === 0 && (
36 | No passkeys found
37 | )}
38 | {passkeys.map((passkey) => (
39 |
40 |
41 | ID: {passkey.id}
42 |
43 |
44 | Name: {passkey.name}
45 |
46 |
47 | Created:{" "}
48 |
49 | {passkey.createdAt.toDateString()}
50 |
51 |
52 |
53 | Last Used:{" "}
54 |
55 | {passkey.lastUsedAt?.toDateString()}
56 |
57 |
58 | passkey.delete()}>
59 | Delete
60 |
61 |
62 | ))}
63 | {/* Users are allowed to have up to 10 passkeys */}
64 |
76 |
77 |
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/app/(chat)/index.tsx:
--------------------------------------------------------------------------------
1 | import { FlatList, RefreshControl, View } from "react-native";
2 | import { Text } from "@/components/Text";
3 | import { Link } from "expo-router";
4 | import { IconSymbol } from "@/components/IconSymbol";
5 | import { database, appwriteConfig } from "@/utils/appwrite";
6 | import { useState, useEffect } from "react";
7 | import { ChatRoom } from "@/utils/types";
8 | import { Query } from "react-native-appwrite";
9 |
10 | export default function Index() {
11 | const [chatRooms, setChatRooms] = useState([]);
12 | const [isRefreshing, setIsRefreshing] = useState(false);
13 |
14 | useEffect(() => {
15 | fetchChatRooms();
16 | }, []);
17 |
18 | const handleRefresh = async () => {
19 | try {
20 | setIsRefreshing(true);
21 | await fetchChatRooms();
22 | } catch (error) {
23 | console.error(error);
24 | } finally {
25 | setIsRefreshing(false);
26 | }
27 | };
28 |
29 | const fetchChatRooms = async () => {
30 | try {
31 | const { documents, total } = await database.listDocuments(
32 | appwriteConfig.db,
33 | appwriteConfig.col.chatRooms,
34 | [Query.limit(100)]
35 | );
36 |
37 | console.log("total", total);
38 |
39 | console.log("docs", JSON.stringify(documents, null, 2));
40 |
41 | // Map the Document objects to ChatRoom objects
42 | const rooms = documents.map((doc) => ({
43 | id: doc.$id,
44 | title: doc.title,
45 | description: doc.description,
46 | isPrivate: doc.isPrivate,
47 | createdAt: new Date(doc.createdAt),
48 | updatedAt: new Date(doc.updatedAt),
49 | }));
50 |
51 | setChatRooms(rooms);
52 | } catch (error) {
53 | console.error(error);
54 | }
55 | };
56 |
57 | return (
58 | item.id}
61 | refreshControl={
62 |
63 | }
64 | renderItem={({ item }) => {
65 | return (
66 |
72 |
84 |
89 |
90 |
91 |
92 | );
93 | }}
94 | contentInsetAdjustmentBehavior="automatic"
95 | contentContainerStyle={{
96 | padding: 16,
97 | gap: 16,
98 | }}
99 | />
100 | );
101 | }
102 |
103 | function ItemTitle({
104 | title,
105 | isPrivate,
106 | }: {
107 | title: string;
108 | isPrivate: boolean;
109 | }) {
110 | return (
111 |
112 | {title}
113 | {isPrivate && }
114 |
115 | );
116 | }
117 |
118 | function ItemTitleAndDescription({
119 | title,
120 | description,
121 | isPrivate,
122 | }: {
123 | title: string;
124 | description: string;
125 | isPrivate: boolean;
126 | }) {
127 | return (
128 |
129 |
130 | {description}
131 |
132 | );
133 | }
134 |
--------------------------------------------------------------------------------
/app/(auth)/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { View, Image, SafeAreaView } from "react-native";
3 |
4 | import * as WebBrowser from "expo-web-browser";
5 | import * as AuthSession from "expo-auth-session";
6 | import {
7 | isClerkAPIResponseError,
8 | useSignIn,
9 | useSSO,
10 | useUser,
11 | } from "@clerk/clerk-expo";
12 | import { ClerkAPIError } from "@clerk/types";
13 | import { Text } from "@/components/Text";
14 | import { Button } from "@/components/Button";
15 |
16 | // Handle any pending authentication sessions
17 | WebBrowser.maybeCompleteAuthSession();
18 |
19 | export default function Index() {
20 | const { startSSOFlow } = useSSO();
21 | const { user, isSignedIn } = useUser();
22 | const { signIn, setActive } = useSignIn();
23 | const [errors, setErrors] = React.useState([]);
24 |
25 | const handleSignInWithGoogle = React.useCallback(async () => {
26 | try {
27 | // Start the authentication process by calling `startSSOFlow()`
28 | const { createdSessionId, setActive, signIn, signUp } =
29 | await startSSOFlow({
30 | strategy: "oauth_google",
31 | // Defaults to current path
32 | redirectUrl: AuthSession.makeRedirectUri(),
33 | });
34 |
35 | // If sign in was successful, set the active session
36 | if (createdSessionId) {
37 | setActive!({ session: createdSessionId });
38 | } else {
39 | // If there is no `createdSessionId`,
40 | // there are missing requirements, such as MFA
41 | // Use the `signIn` or `signUp` returned from `startSSOFlow`
42 | // to handle next steps
43 | }
44 | } catch (err) {
45 | // See https://clerk.com/docs/custom-flows/error-handling
46 | // for more info on error handling
47 | if (isClerkAPIResponseError(err)) setErrors(err.errors);
48 | console.error(JSON.stringify(err, null, 2));
49 | }
50 | }, []);
51 |
52 | const signInWithPasskey = async () => {
53 | // 'discoverable' lets the user choose a passkey
54 | // without auto-filling any of the options
55 | try {
56 | const signInAttempt = await signIn?.authenticateWithPasskey({
57 | flow: "discoverable",
58 | });
59 |
60 | if (signInAttempt?.status === "complete") {
61 | if (setActive !== undefined) {
62 | await setActive({ session: signInAttempt.createdSessionId });
63 | }
64 | } else {
65 | // If the status is not complete, check why. User may need to
66 | // complete further steps.
67 | console.error(JSON.stringify(signInAttempt, null, 2));
68 | }
69 | } catch (err) {
70 | // See https://clerk.com/docs/custom-flows/error-handling
71 | // for more info on error handling
72 | console.error("Error:", JSON.stringify(err, null, 2));
73 | }
74 | };
75 |
76 | return (
77 |
78 | {/* spacer */}
79 |
80 |
81 |
89 |
90 |
94 |
95 | Modern Chat App
96 |
97 | Sign in to continue
98 | {errors.map((error) => (
99 | {error.code}
100 | ))}
101 |
102 |
103 | {/* spacer */}
104 |
105 |
122 |
140 |
141 |
142 | );
143 | }
144 |
--------------------------------------------------------------------------------
/app/(chat)/[chat].tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Link, Stack, useLocalSearchParams } from "expo-router";
3 | import {
4 | ActivityIndicator,
5 | Image,
6 | KeyboardAvoidingView,
7 | Platform,
8 | Pressable,
9 | TextInput,
10 | View,
11 | } from "react-native";
12 | import { Text } from "@/components/Text";
13 | import { Message, ChatRoom } from "@/utils/types";
14 | import { database, appwriteConfig, client } from "@/utils/appwrite";
15 | import { ID, Query } from "react-native-appwrite";
16 | import { LegendList } from "@legendapp/list";
17 | import { SafeAreaView } from "react-native-safe-area-context";
18 | import { useHeaderHeight } from "@react-navigation/elements";
19 | import { IconSymbol } from "@/components/IconSymbol";
20 | import { useUser } from "@clerk/clerk-expo";
21 | import { FlatList } from "react-native";
22 | import { Secondary, Primary, Red } from "@/colors";
23 | export default function ChatRoomScreen() {
24 | const { chat: chatRoomId } = useLocalSearchParams();
25 | const { user } = useUser();
26 |
27 | if (!chatRoomId) {
28 | return We couldn't find this chat room 🥲;
29 | }
30 |
31 | const [messageContent, setMessageContent] = React.useState("");
32 | const [chatRoom, setChatRoom] = React.useState(null);
33 | const [messages, setMessages] = React.useState([]);
34 | const [isLoading, setIsLoading] = React.useState(true);
35 | const headerHeight = Platform.OS === "ios" ? useHeaderHeight() : 0;
36 | const textInputRef = React.useRef(null);
37 |
38 | React.useEffect(() => {
39 | handleFirstLoad();
40 | }, []);
41 |
42 | // Focus the text input when the component mounts
43 | React.useEffect(() => {
44 | if (!isLoading) {
45 | // Wait until loading is complete before focusing
46 | setTimeout(() => {
47 | textInputRef.current?.focus();
48 | }, 100);
49 | }
50 | }, [isLoading]);
51 |
52 | // Subscribe to messages
53 | React.useEffect(() => {
54 | // listen for updates on the chat room document
55 | const channel = `databases.${appwriteConfig.db}.collections.${appwriteConfig.col.chatRooms}.documents.${chatRoomId}`;
56 |
57 | const unsubscribe = client.subscribe(channel, () => {
58 | console.log("chat room updated");
59 | getMessages();
60 | });
61 |
62 | return () => {
63 | unsubscribe();
64 | };
65 | }, [chatRoomId]);
66 |
67 | async function handleFirstLoad() {
68 | try {
69 | await getChatRoom();
70 | await getMessages();
71 | } catch (error) {
72 | console.error(error);
73 | } finally {
74 | setIsLoading(false);
75 | }
76 | }
77 |
78 | // get chat room info by chat id
79 | async function getChatRoom() {
80 | const document = await database.getDocument(
81 | appwriteConfig.db,
82 | appwriteConfig.col.chatRooms,
83 | chatRoomId as string
84 | );
85 |
86 | /**
87 | * First, we need to cast the document to unknown to avoid type errors
88 | * Then, we need to cast the document to ChatRoom to get the correct type 🤷♂️
89 | */
90 | setChatRoom(document as unknown as ChatRoom);
91 | }
92 |
93 | // get messages associated with chat id
94 | async function getMessages() {
95 | try {
96 | const { documents, total } = await database.listDocuments(
97 | appwriteConfig.db,
98 | appwriteConfig.col.message,
99 | [
100 | Query.equal("chatRoomId", chatRoomId),
101 | Query.limit(100),
102 | Query.orderDesc("$createdAt"),
103 | ]
104 | );
105 |
106 | // Reverse the documents array to display in chronological order
107 | documents.reverse();
108 |
109 | setMessages(documents as unknown as Message[]);
110 | } catch (error) {
111 | console.error(error);
112 | }
113 | }
114 |
115 | async function handleSendMessage() {
116 | if (messageContent.trim() === "") return;
117 |
118 | const message = {
119 | content: messageContent,
120 | senderId: user?.id!,
121 | senderName: user?.fullName ?? "Anonymous",
122 | senderPhoto: user?.imageUrl ?? "",
123 | chatRoomId: chatRoomId as string,
124 | };
125 |
126 | try {
127 | // create a new message document
128 | await database.createDocument(
129 | appwriteConfig.db,
130 | appwriteConfig.col.message,
131 | ID.unique(),
132 | message
133 | );
134 | setMessageContent("");
135 |
136 | console.log("updating chat room", chatRoomId);
137 | // Update chat room updatedAt field
138 | await database.updateDocument(
139 | appwriteConfig.db,
140 | appwriteConfig.col.chatRooms,
141 | chatRoomId as string,
142 | { $updatedAt: new Date().toISOString() }
143 | );
144 | } catch (error) {
145 | console.error(error);
146 | }
147 | }
148 |
149 | if (isLoading) {
150 | return (
151 |
152 |
153 |
154 | );
155 | }
156 |
157 | return (
158 | <>
159 | (
163 |
169 |
170 |
171 | ),
172 | }}
173 | />
174 |
175 |
180 | {
183 | const isSender = item.senderId === user?.id;
184 | return (
185 |
196 | {!isSender && (
197 |
201 | )}
202 |
210 |
211 | {item.senderName}
212 |
213 | {item.content}
214 |
220 | {new Date(item.$createdAt!).toLocaleTimeString([], {
221 | hour: "2-digit",
222 | minute: "2-digit",
223 | })}
224 |
225 |
226 |
227 | );
228 | }}
229 | keyExtractor={(item) => item?.$id ?? "unknown"}
230 | contentContainerStyle={{ padding: 10 }}
231 | recycleItems={true}
232 | initialScrollIndex={messages.length - 1}
233 | alignItemsAtEnd // Aligns to the end of the screen, so if there's only a few items there will be enough padding at the top to make them appear to be at the bottom.
234 | maintainScrollAtEnd // prop will check if you are already scrolled to the bottom when data changes, and if so it keeps you scrolled to the bottom.
235 | maintainScrollAtEndThreshold={0.5} // prop will check if you are already scrolled to the bottom when data changes, and if so it keeps you scrolled to the bottom.
236 | maintainVisibleContentPosition //Automatically adjust item positions when items are added/removed/resized above the viewport so that there is no shift in the visible content.
237 | estimatedItemSize={100} // estimated height of the item
238 | // getEstimatedItemSize={(info) => { // use if items are different known sizes
239 | // console.log("info", info);
240 | // }}
241 | />
242 |
253 |
268 |
277 |
282 |
283 |
284 |
285 |
286 | >
287 | );
288 | }
289 |
--------------------------------------------------------------------------------