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