├── .gitignore ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── default-avatar.png ├── error.svg ├── facebook.svg ├── google.svg ├── icon.svg ├── illustration.svg ├── reactions-icon │ ├── angry.svg │ ├── care.svg │ ├── haha.svg │ ├── like.svg │ ├── love.svg │ ├── sad.svg │ └── wow.svg └── reactions │ ├── angry.gif │ ├── care.gif │ ├── haha.gif │ ├── like.gif │ ├── love.gif │ ├── sad.gif │ └── wow.gif ├── src ├── App.tsx ├── components │ ├── Alert.tsx │ ├── Chat │ │ ├── AvatarFromId.tsx │ │ ├── ChatHeader.tsx │ │ ├── ChatView.tsx │ │ ├── ConversationSettings.tsx │ │ ├── ReactionPopup.tsx │ │ ├── ReactionStatus.tsx │ │ └── ReplyBadge.tsx │ ├── ClickAwayListener.tsx │ ├── FileIcon.tsx │ ├── Group │ │ ├── AddMembers.tsx │ │ ├── Admin.tsx │ │ ├── Members.tsx │ │ └── ViewGroup.tsx │ ├── Home │ │ ├── CreateConversation.tsx │ │ ├── SelectConversation.tsx │ │ ├── SideBar.tsx │ │ └── UserInfo.tsx │ ├── Icon │ │ ├── GifIcon.tsx │ │ ├── ReplyIcon.tsx │ │ └── StickerIcon.tsx │ ├── ImageView.tsx │ ├── Input │ │ ├── EmojiPicker.tsx │ │ ├── GifPicker.tsx │ │ ├── InputSection.tsx │ │ └── StickerPicker.tsx │ ├── Media │ │ ├── Files.tsx │ │ ├── Image.tsx │ │ └── ViewMedia.tsx │ ├── Message │ │ ├── LeftMessage.tsx │ │ └── RightMessage.tsx │ ├── PrivateRoute.tsx │ ├── Skeleton.tsx │ └── SpriteRenderer.tsx ├── hooks │ ├── useCollectionQuery.ts │ ├── useDocumentQuery.ts │ ├── useFetch.tsx │ ├── useLastMessage.ts │ ├── useQueryParams.ts │ └── useUsersInfo.ts ├── main.tsx ├── pages │ ├── Chat.tsx │ ├── Home.tsx │ └── SignIn.tsx ├── shared │ ├── configs.ts │ ├── constants.ts │ ├── firebase.ts │ ├── types.ts │ └── utils.ts ├── store │ └── index.ts ├── styles │ └── index.css └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | .env -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | "# chat-vite-firebase-react-typescript" 2 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | FireVerse 7 | 8 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fireverse", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "tsc && vite build", 7 | "preview": "vite preview" 8 | }, 9 | "dependencies": { 10 | "dayjs": "^1.10.7", 11 | "emoji-mart": "^3.0.1", 12 | "firebase": "^9.6.4", 13 | "lodash.kebabcase": "^4.1.1", 14 | "react": "^17.0.2", 15 | "react-cssfx-loading": "^1.0.3", 16 | "react-dom": "^17.0.2", 17 | "react-infinite-scroll-component": "^6.1.0", 18 | "react-router-dom": "^6.2.1", 19 | "zustand": "^3.6.9" 20 | }, 21 | "devDependencies": { 22 | "@types/emoji-mart": "^3.0.9", 23 | "@types/react": "^17.0.33", 24 | "@types/react-dom": "^17.0.10", 25 | "@vitejs/plugin-react": "^1.0.7", 26 | "autoprefixer": "^10.4.2", 27 | "postcss": "^8.4.5", 28 | "prettier-plugin-tailwindcss": "^0.1.5", 29 | "tailwindcss": "^3.0.17", 30 | "typescript": "^4.4.4", 31 | "vite": "^2.7.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/default-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNevo/chat-vite-firebase-react-typescript/1099109b47583c3baee6882a62c7ce4ca0beffcb/public/default-avatar.png -------------------------------------------------------------------------------- /public/error.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/facebook.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/google.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 19 | 20 | 21 | 22 | 23 | 27 | 28 | 29 | 30 | 31 | 34 | 35 | 36 | 37 | 38 | 39 | 42 | 43 | 44 | 45 | 46 | 47 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /public/illustration.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /public/reactions-icon/angry.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/reactions-icon/care.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/reactions-icon/haha.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/reactions-icon/like.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/reactions-icon/love.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/reactions-icon/sad.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/reactions-icon/wow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/reactions/angry.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNevo/chat-vite-firebase-react-typescript/1099109b47583c3baee6882a62c7ce4ca0beffcb/public/reactions/angry.gif -------------------------------------------------------------------------------- /public/reactions/care.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNevo/chat-vite-firebase-react-typescript/1099109b47583c3baee6882a62c7ce4ca0beffcb/public/reactions/care.gif -------------------------------------------------------------------------------- /public/reactions/haha.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNevo/chat-vite-firebase-react-typescript/1099109b47583c3baee6882a62c7ce4ca0beffcb/public/reactions/haha.gif -------------------------------------------------------------------------------- /public/reactions/like.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNevo/chat-vite-firebase-react-typescript/1099109b47583c3baee6882a62c7ce4ca0beffcb/public/reactions/like.gif -------------------------------------------------------------------------------- /public/reactions/love.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNevo/chat-vite-firebase-react-typescript/1099109b47583c3baee6882a62c7ce4ca0beffcb/public/reactions/love.gif -------------------------------------------------------------------------------- /public/reactions/sad.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNevo/chat-vite-firebase-react-typescript/1099109b47583c3baee6882a62c7ce4ca0beffcb/public/reactions/sad.gif -------------------------------------------------------------------------------- /public/reactions/wow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNevo/chat-vite-firebase-react-typescript/1099109b47583c3baee6882a62c7ce4ca0beffcb/public/reactions/wow.gif -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect } from "react"; 2 | import { Route, Routes } from "react-router-dom"; 3 | import { auth, db } from "./shared/firebase"; 4 | import { doc, setDoc } from "firebase/firestore"; 5 | 6 | import BarWave from "react-cssfx-loading/src/BarWave"; 7 | import Chat from "./pages/Chat"; 8 | import Home from "./pages/Home"; 9 | import PrivateRoute from "./components/PrivateRoute"; 10 | import SignIn from "./pages/SignIn"; 11 | import { onAuthStateChanged } from "firebase/auth"; 12 | import { useStore } from "./store"; 13 | 14 | const App: FC = () => { 15 | const currentUser = useStore((state) => state.currentUser); 16 | const setCurrentUser = useStore((state) => state.setCurrentUser); 17 | 18 | useEffect(() => { 19 | onAuthStateChanged(auth, (user) => { 20 | if (user) { 21 | setCurrentUser(user); 22 | setDoc(doc(db, "users", user.uid), { 23 | uid: user.uid, 24 | email: user.email, 25 | displayName: user.displayName, 26 | photoURL: user.photoURL, 27 | phoneNumber: user.phoneNumber || user.providerData?.[0]?.phoneNumber, 28 | }); 29 | } else setCurrentUser(null); 30 | }); 31 | }, []); 32 | 33 | if (typeof currentUser === "undefined") 34 | return ( 35 |
36 | 37 |
38 | ); 39 | 40 | return ( 41 | 42 | 46 | 47 | 48 | } 49 | /> 50 | } /> 51 | 55 | 56 | 57 | } 58 | /> 59 | 60 | ); 61 | }; 62 | 63 | export default App; -------------------------------------------------------------------------------- /src/components/Alert.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect } from "react"; 2 | 3 | interface AlertProps { 4 | isOpened: boolean; 5 | setIsOpened: (value: boolean) => void; 6 | text: string; 7 | isError?: boolean; 8 | duration?: number; 9 | } 10 | 11 | const Alert: FC = ({ 12 | isOpened, 13 | setIsOpened, 14 | text, 15 | isError = false, 16 | duration = 5000, 17 | }) => { 18 | useEffect(() => { 19 | if (isOpened) { 20 | setTimeout(() => { 21 | setIsOpened(false); 22 | }, duration); 23 | } 24 | }, [isOpened]); 25 | 26 | return ( 27 |
36 | {text} 37 |
38 | ); 39 | }; 40 | 41 | export default Alert; 42 | -------------------------------------------------------------------------------- /src/components/Chat/AvatarFromId.tsx: -------------------------------------------------------------------------------- 1 | import { DEFAULT_AVATAR, IMAGE_PROXY } from "../../shared/constants"; 2 | 3 | import { FC } from "react"; 4 | import Skeleton from "../Skeleton"; 5 | import { useUsersInfo } from "../../hooks/useUsersInfo"; 6 | 7 | interface AvatarFromIdProps { 8 | uid: string; 9 | size?: number; 10 | } 11 | 12 | const AvatarFromId: FC = ({ uid, size = 30 }) => { 13 | const { data, loading, error } = useUsersInfo([uid]); 14 | 15 | if (loading) 16 | return ( 17 | 21 | ); 22 | 23 | if (error) 24 | return ( 25 | 30 | ); 31 | 32 | return ( 33 | 39 | ); 40 | }; 41 | 42 | export default AvatarFromId; 43 | -------------------------------------------------------------------------------- /src/components/Chat/ChatHeader.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | 3 | import { ConversationInfo } from "../../shared/types"; 4 | import ConversationSettings from "./ConversationSettings"; 5 | import { IMAGE_PROXY } from "../../shared/constants"; 6 | import { Link } from "react-router-dom"; 7 | import Skeleton from "../Skeleton"; 8 | import ViewGroup from "../Group/ViewGroup"; 9 | import ViewMedia from "../Media/ViewMedia"; 10 | import { useStore } from "../../store"; 11 | import { useUsersInfo } from "../../hooks/useUsersInfo"; 12 | 13 | interface ChatHeaderProps { 14 | conversation: ConversationInfo; 15 | } 16 | 17 | const ChatHeader: FC = ({ conversation }) => { 18 | const { data: users, loading } = useUsersInfo(conversation.users); 19 | const currentUser = useStore((state) => state.currentUser); 20 | 21 | const filtered = users?.filter((user) => user.id !== currentUser?.uid); 22 | 23 | const [isConversationSettingsOpened, setIsConversationSettingsOpened] = 24 | useState(false); 25 | const [isGroupMembersOpened, setIsGroupMembersOpened] = useState(false); 26 | const [isViewMediaOpened, setIsViewMediaOpened] = useState(false); 27 | 28 | return ( 29 | <> 30 |
31 |
32 | 33 | 34 | 35 | { 36 | loading ? ( 37 | 38 | ) : ( 39 | <> 40 | { 41 | conversation.users.length === 2 ? ( 42 | 47 | ) : ( 48 | <> 49 | { 50 | conversation?.group?.groupImage ? ( 51 | 56 | ) : ( 57 |
58 | 63 | 68 |
69 | ) 70 | } 71 | 72 | )} 73 | 74 | ) 75 | } 76 | 77 | {loading ? ( 78 | 79 | ) : ( 80 |

81 | { 82 | conversation.users.length > 2 && conversation?.group?.groupName 83 | ? conversation.group.groupName 84 | : filtered 85 | ?.map((user) => user.data()?.displayName) 86 | .slice(0, 3) 87 | .join(", ") 88 | } 89 |

90 | )} 91 |
92 | 93 | {!loading && ( 94 | <> 95 | {conversation.users.length > 2 && ( 96 | 99 | )} 100 | 101 | 104 | 105 | )} 106 |
107 | 108 | {isConversationSettingsOpened && ( 109 | 114 | )} 115 | 116 | {isGroupMembersOpened && ( 117 | 121 | )} 122 | 123 | { 124 | isViewMediaOpened && 125 | } 126 | 127 | ); 128 | }; 129 | 130 | export default ChatHeader; 131 | -------------------------------------------------------------------------------- /src/components/Chat/ChatView.tsx: -------------------------------------------------------------------------------- 1 | import { ConversationInfo, MessageItem } from "../../shared/types"; 2 | import { FC, Fragment, useEffect, useRef, useState } from "react"; 3 | import { 4 | collection, 5 | doc, 6 | limitToLast, 7 | orderBy, 8 | query, 9 | updateDoc, 10 | } from "firebase/firestore"; 11 | 12 | import AvatarFromId from "./AvatarFromId"; 13 | import InfiniteScroll from "react-infinite-scroll-component"; 14 | import LeftMessage from "../Message/LeftMessage"; 15 | import RightMessage from "../Message/RightMessage"; 16 | import Spin from "react-cssfx-loading/src/Spin"; 17 | import { db } from "../../shared/firebase"; 18 | import { useCollectionQuery } from "../../hooks/useCollectionQuery"; 19 | import { useParams } from "react-router-dom"; 20 | import { useStore } from "../../store"; 21 | 22 | interface ChatViewProps { 23 | conversation: ConversationInfo; 24 | inputSectionOffset: number; 25 | replyInfo: any; 26 | setReplyInfo: (value: any) => void; 27 | } 28 | 29 | const ChatView: FC = ({ 30 | conversation, 31 | inputSectionOffset, 32 | replyInfo, 33 | setReplyInfo, 34 | }) => { 35 | const { id: conversationId } = useParams(); 36 | const currentUser = useStore((state) => state.currentUser); 37 | const scrollBottomRef = useRef(null); 38 | const [limitCount, setLimitCount] = useState(10); 39 | const { data, loading, error } = useCollectionQuery( 40 | `conversation-data-${conversationId}-${limitCount}`, 41 | query( 42 | collection(db, "conversations", conversationId as string, "messages"), 43 | orderBy("createdAt"), 44 | limitToLast(limitCount) 45 | ) 46 | ); 47 | const dataRef = useRef(data); 48 | const conversationIdRef = useRef(conversationId); 49 | const isWindowFocus = useRef(true); 50 | 51 | useEffect(() => { 52 | dataRef.current = data; 53 | }, [data]); 54 | 55 | useEffect(() => { 56 | conversationIdRef.current = conversationId; 57 | }, [conversationId]); 58 | 59 | useEffect(() => { 60 | if (isWindowFocus.current) updateSeenStatus(); 61 | 62 | scrollBottomRef.current?.scrollIntoView(); 63 | 64 | setTimeout(() => { 65 | scrollBottomRef.current?.scrollIntoView(); 66 | }, 100); 67 | }, [data?.docs?.slice(-1)?.[0]?.id || ""]); 68 | 69 | const updateSeenStatus = () => { 70 | if (dataRef.current?.empty) return; 71 | 72 | const lastDoc = dataRef.current?.docs?.slice(-1)?.[0]; 73 | 74 | if (!lastDoc) return; 75 | 76 | updateDoc(doc(db, "conversations", conversationIdRef.current as string), { 77 | [`seen.${currentUser?.uid}`]: lastDoc.id, 78 | }); 79 | }; 80 | 81 | useEffect(() => { 82 | const focusHandler = () => { 83 | isWindowFocus.current = true; 84 | 85 | updateSeenStatus(); 86 | }; 87 | 88 | const blurHandler = () => { 89 | isWindowFocus.current = false; 90 | }; 91 | 92 | addEventListener("focus", focusHandler); 93 | addEventListener("blur", blurHandler); 94 | 95 | return () => { 96 | removeEventListener("focus", focusHandler); 97 | removeEventListener("blur", blurHandler); 98 | }; 99 | }, []); 100 | 101 | if (loading) 102 | return ( 103 |
104 | 105 |
106 | ); 107 | 108 | if (error) 109 | return ( 110 |
111 |

Something went wrong

112 |
113 | ); 114 | 115 | if (data?.empty) 116 | return ( 117 |
118 |

119 | No message recently. Start chatting now. 120 |

121 |
122 | ); 123 | 124 | return ( 125 | setLimitCount((prev) => prev + 10)} 128 | inverse 129 | hasMore={(data?.size as number) >= limitCount} 130 | loader={ 131 |
132 | 133 |
134 | } 135 | style={{ display: "flex", flexDirection: "column-reverse" }} 136 | height={`calc(100vh - ${144 + inputSectionOffset}px)`} 137 | > 138 |
139 | { 140 | data?.docs 141 | .map((doc) => ({ id: doc.id, ...doc.data() } as MessageItem)) 142 | .map( 143 | (item, index) => ( 144 | 145 | { 146 | item.sender === currentUser?.uid ? ( 147 | 152 | ) : ( 153 | 161 | ) 162 | } 163 | { 164 | Object.entries(conversation.seen).filter( 165 | ([key, value]) => key !== currentUser?.uid && value === item.id 166 | ).length > 0 && ( 167 |
168 | { 169 | Object.entries(conversation.seen) 170 | .filter( 171 | ([key, value]) => key !== currentUser?.uid && value === item.id 172 | ) 173 | .map(([key, value]) => ( 174 | 175 | )) 176 | } 177 |
178 | ) 179 | } 180 |
181 | ) 182 | ) 183 | } 184 |
185 |
186 |
187 | ); 188 | }; 189 | 190 | export default ChatView; 191 | -------------------------------------------------------------------------------- /src/components/Chat/ConversationSettings.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, FC, FormEvent, useRef, useState } from "react"; 2 | import { arrayRemove, doc, updateDoc } from "firebase/firestore"; 3 | import { db, storage } from "../../shared/firebase"; 4 | import { getDownloadURL, ref, uploadBytes } from "firebase/storage"; 5 | import { useNavigate, useParams } from "react-router-dom"; 6 | 7 | import Alert from "../Alert"; 8 | import { ConversationInfo } from "../../shared/types"; 9 | import { THEMES } from "../../shared/constants"; 10 | import { formatFileName } from "../../shared/utils"; 11 | import { useStore } from "../../store"; 12 | 13 | interface ConversationConfigProps { 14 | conversation: ConversationInfo; 15 | setIsOpened: (value: boolean) => void; 16 | setMediaViewOpened: (value: boolean) => void; 17 | } 18 | 19 | const ConversationSettings: FC = ({ 20 | conversation, 21 | setIsOpened, 22 | setMediaViewOpened, 23 | }) => { 24 | const { id: conversationId } = useParams(); 25 | const currentUser = useStore((state) => state.currentUser); 26 | const navigate = useNavigate(); 27 | const [isChangeChatNameOpened, setIsChangeChatNameOpened] = useState(false); 28 | const [chatNameInputValue, setChatNameInputValue] = useState( 29 | conversation?.group?.groupName || "" 30 | ); 31 | const [isChangeThemeOpened, setIsChangeThemeOpened] = useState(false); 32 | const [isAlertOpened, setIsAlertOpened] = useState(false); 33 | const [alertText, setAlertText] = useState(""); 34 | const fileInputRef = useRef(null); 35 | const handleFormSubmit = (e: FormEvent) => { 36 | e.preventDefault(); 37 | 38 | if (!chatNameInputValue.trim()) return; 39 | 40 | setIsOpened(false); 41 | updateDoc(doc(db, "conversations", conversationId as string), { 42 | "group.groupName": chatNameInputValue.trim(), 43 | }); 44 | }; 45 | 46 | const handleFileInputChange = async (e: ChangeEvent) => { 47 | const file = e.target.files?.[0]; 48 | if (!file) return; 49 | 50 | if (!file.type.startsWith("image")) { 51 | setAlertText("File is not an image"); 52 | setIsAlertOpened(true); 53 | 54 | return; 55 | } 56 | 57 | const FIVE_MB = 1024 * 1024 * 5; 58 | 59 | if (file.size > FIVE_MB) { 60 | setAlertText("Max image size is 20MB"); 61 | setIsAlertOpened(true); 62 | 63 | return; 64 | } 65 | 66 | setIsOpened(false); 67 | 68 | const fileReference = ref(storage, formatFileName(file.name)); 69 | 70 | await uploadBytes(fileReference, file); 71 | 72 | const downloadURL = await getDownloadURL(fileReference); 73 | 74 | updateDoc(doc(db, "conversations", conversationId as string), { 75 | "group.groupImage": downloadURL, 76 | }); 77 | }; 78 | 79 | const changeTheme = (value: string) => { 80 | updateDoc(doc(db, "conversations", conversationId as string), { 81 | theme: value, 82 | }); 83 | }; 84 | 85 | const leaveGroup = () => { 86 | updateDoc(doc(db, "conversations", conversationId as string), { 87 | users: arrayRemove(currentUser?.uid as string), 88 | "group.admins": arrayRemove(currentUser?.uid as string), 89 | "group.groupImage": conversation.group?.groupImage, 90 | "group.groupName": conversation.group?.groupName, 91 | }); 92 | 93 | navigate("/"); 94 | }; 95 | 96 | return ( 97 |
setIsOpened(false)} 99 | className={`animate-fade-in fixed top-0 left-0 z-20 flex h-full w-full items-center justify-center bg-[#00000080] transition-all duration-300`} 100 | > 101 |
e.stopPropagation()} 103 | className="bg-dark mx-2 w-full max-w-[500px] rounded-lg" 104 | > 105 |
106 |
107 |
108 |

109 | Conversation settings 110 |

111 |
112 |
113 | 119 |
120 |
121 | 122 |
123 | {conversation.users.length > 2 && ( 124 | <> 125 | 140 | {isChangeChatNameOpened && ( 141 |
142 |
143 | setChatNameInputValue(e.target.value)} 146 | className="border-dark-lighten bg-dark h-full w-full rounded-lg border p-2 outline-none transition duration-300 focus:border-gray-500" 147 | type="text" 148 | placeholder="Chat name" 149 | /> 150 |
151 | 154 |
155 | )} 156 | 163 | 164 | 172 | 173 | 179 | 180 | )} 181 | 196 | 197 | {isChangeThemeOpened && ( 198 |
199 | {THEMES.map((theme) => ( 200 |
changeTheme(theme)} 204 | className={`h-14 w-14 cursor-pointer rounded-full ${ 205 | conversation.theme === theme ? "check-overlay" : "" 206 | }`} 207 | >
208 | ))} 209 |
210 | )} 211 | 221 | 222 | {conversation.users.length > 2 && ( 223 | 232 | )} 233 |
234 |
235 |
236 | ); 237 | }; 238 | 239 | export default ConversationSettings; 240 | -------------------------------------------------------------------------------- /src/components/Chat/ReactionPopup.tsx: -------------------------------------------------------------------------------- 1 | import { FC, Ref } from "react"; 2 | import { doc, updateDoc } from "firebase/firestore"; 3 | 4 | import { REACTIONS_UI } from "../../shared/constants"; 5 | import { db } from "../../shared/firebase"; 6 | import { useParams } from "react-router-dom"; 7 | import { useStore } from "../../store"; 8 | 9 | interface ReactionPopupProps { 10 | position: "left" | "right"; 11 | forwardedRef: Ref; 12 | setIsOpened: (value: boolean) => void; 13 | messageId: string; 14 | currentReaction: number; 15 | } 16 | 17 | const ReactionPopup: FC = ({ 18 | position, 19 | forwardedRef, 20 | setIsOpened, 21 | messageId, 22 | currentReaction, 23 | }) => { 24 | const { id: conversationId } = useParams(); 25 | const currentUser = useStore((state) => state.currentUser); 26 | const updateReaction = (value: number) => { 27 | updateDoc( 28 | doc(db, "conversations", conversationId as string, "messages", messageId),{ 29 | [`reactions.${currentUser?.uid}`]: value, 30 | } 31 | ); 32 | }; 33 | 34 | return ( 35 |
41 | {Object.entries(REACTIONS_UI).map(([key, value], index) => ( 42 |
50 | { 52 | if (index + 1 === currentReaction) updateReaction(0); 53 | else updateReaction(index + 1); 54 | setIsOpened(false); 55 | }} 56 | title={key} 57 | className={`h-7 w-7 origin-bottom cursor-pointer transition duration-300 hover:scale-[115%]`} 58 | src={value.gif} 59 | alt="" 60 | /> 61 |
62 | ))} 63 |
64 | ); 65 | }; 66 | 67 | export default ReactionPopup; 68 | -------------------------------------------------------------------------------- /src/components/Chat/ReactionStatus.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | import { IMAGE_PROXY, REACTIONS_UI } from "../../shared/constants"; 3 | 4 | import { MessageItem } from "../../shared/types"; 5 | import Spin from "react-cssfx-loading/src/Spin"; 6 | import { useUsersInfo } from "../../hooks/useUsersInfo"; 7 | 8 | interface ReactionStatusProps { 9 | position: "left" | "right" | "left-tab"; 10 | message: MessageItem; 11 | } 12 | 13 | const ReactionStatus: FC = ({ message, position }) => { 14 | const { 15 | data: usersInfo, 16 | loading, 17 | error, 18 | } = useUsersInfo(Object.keys(message.reactions || {})); 19 | 20 | const [isReactionStatusOpened, setIsReactionStatusOpened] = useState(false); 21 | 22 | return ( 23 | <> 24 |
setIsReactionStatusOpened(true)} 26 | className={`bg-dark-lighten border-dark absolute top-full flex -translate-y-1/2 cursor-pointer items-center gap-[2px] rounded-lg border px-2 text-sm ${ 27 | position === "right" 28 | ? "right-8" 29 | : position === "left-tab" 30 | ? "left-[70px]" 31 | : "left-8" 32 | }` 33 | } 34 | > 35 | {Object.entries( 36 | Object.entries(message.reactions).reduce((acc, [key, value]) => { 37 | if (value) acc[value] = (acc[value] || 0) + 1; 38 | return acc; 39 | }, {} as { [key: number]: number }) 40 | ) 41 | .sort(([key1, value1], [key2, value2]) => value1 - value2) 42 | .slice(0, 3) 43 | .map(([key, value]) => ( 44 | 50 | )) 51 | } 52 | 53 | 54 | { 55 | Object.entries(message.reactions).filter(([key, value]) => value) 56 | .length 57 | } 58 | 59 |
60 | 61 | {isReactionStatusOpened && ( 62 |
setIsReactionStatusOpened(false)} 64 | className="animate-fade-in fixed top-0 left-0 z-20 flex h-full w-full items-center justify-center bg-[#00000080]" 65 | > 66 |
e.stopPropagation()} 68 | className="bg-dark flex h-96 w-screen max-w-[400px] flex-col rounded-lg" 69 | > 70 |
71 |
72 |
73 |

Reactions

74 |
75 |
76 | 82 |
83 |
84 | 85 | {loading || error ? ( 86 |
87 | 88 |
89 | ) : ( 90 |
91 | {Object.entries(message.reactions) 92 | .filter(([key, value]) => value) 93 | .map(([key, value]) => ( 94 |
98 |
99 | user.id === key)?.data() 103 | ?.photoURL 104 | )} 105 | alt="" 106 | /> 107 |

108 | { 109 | usersInfo?.find((user) => user.id === key)?.data() 110 | ?.displayName 111 | } 112 |

113 |
114 | 115 | 120 |
121 | )) 122 | } 123 |
124 | )} 125 |
126 |
127 | )} 128 | 129 | ); 130 | }; 131 | 132 | export default ReactionStatus; 133 | -------------------------------------------------------------------------------- /src/components/Chat/ReplyBadge.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | 3 | import Alert from "../Alert"; 4 | import { db } from "../../shared/firebase"; 5 | import { doc } from "firebase/firestore"; 6 | import { useDocumentQuery } from "../../hooks/useDocumentQuery"; 7 | import { useParams } from "react-router-dom"; 8 | 9 | interface ReplyBadgeProps { 10 | messageId: string; 11 | } 12 | 13 | const ReplyBadge: FC = ({ messageId }) => { 14 | const { id: conversationId } = useParams(); 15 | const [isAlertOpened, setIsAlertOpened] = useState(false); 16 | const { data, loading, error } = useDocumentQuery( 17 | `message-${messageId}`, 18 | doc(db, "conversations", conversationId as string, "messages", messageId) 19 | ); 20 | 21 | if (loading || error) 22 | return
; 23 | 24 | return ( 25 | <> 26 |
{ 28 | const el = document.querySelector(`#message-${messageId}`); 29 | 30 | if (el) el.scrollIntoView({ behavior: "smooth" }); 31 | else setIsAlertOpened(true); 32 | }} 33 | className="cursor-pointer rounded-lg bg-[#4E4F50] p-2 opacity-60" 34 | > 35 | { 36 | data?.data()?.type === "text" 37 | ? (

{data?.data()?.content}

) : data?.data()?.type === "image" 38 | ? ("An image") : data?.data()?.type === "file" 39 | ? ("A file") : data?.data()?.type === "sticker" 40 | ? ("A sticker") : ("Message has been removed") 41 | } 42 |
43 | 48 | 49 | ); 50 | }; 51 | 52 | export default ReplyBadge; 53 | -------------------------------------------------------------------------------- /src/components/ClickAwayListener.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useRef } from "react"; 2 | 3 | interface ClickAwayListenerProps { 4 | onClickAway: () => void; 5 | children: (ref: any) => any; 6 | } 7 | 8 | const ClickAwayListener: FC = ({ 9 | children, 10 | onClickAway, 11 | }) => { 12 | const childrenRef = useRef(null); 13 | 14 | useEffect(() => { 15 | const handler = (e: any) => { 16 | if (childrenRef.current && !childrenRef.current.contains(e.target)) { 17 | onClickAway(); 18 | } 19 | }; 20 | 21 | window.addEventListener("click", handler); 22 | 23 | return () => window.removeEventListener("click", handler); 24 | }, []); 25 | 26 | return <>{children(childrenRef)}; 27 | }; 28 | 29 | export default ClickAwayListener; 30 | -------------------------------------------------------------------------------- /src/components/FileIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | 3 | import { FILE_ICON } from "../shared/constants"; 4 | 5 | interface FileIconProps { 6 | extension: string; 7 | className?: string; 8 | } 9 | 10 | const FileIcon: FC = ({ extension, className }) => { 11 | const [isError, setIsError] = useState(false); 12 | 13 | if (isError) return ; 14 | 15 | return ( 16 | setIsError(true)} 19 | src={FILE_ICON(extension)} 20 | > 21 | ); 22 | }; 23 | 24 | export default FileIcon; 25 | -------------------------------------------------------------------------------- /src/components/Group/AddMembers.tsx: -------------------------------------------------------------------------------- 1 | import { ConversationInfo, SavedUser } from "../../shared/types"; 2 | import { 3 | arrayUnion, 4 | collection, 5 | doc, 6 | query, 7 | updateDoc, 8 | where, 9 | } from "firebase/firestore"; 10 | 11 | import { FC } from "react"; 12 | import { IMAGE_PROXY } from "../../shared/constants"; 13 | import Spin from "react-cssfx-loading/src/Spin"; 14 | import { db } from "../../shared/firebase"; 15 | import { useCollectionQuery } from "../../hooks/useCollectionQuery"; 16 | import { useParams } from "react-router-dom"; 17 | 18 | interface AddMembersProps { 19 | conversations: ConversationInfo; 20 | } 21 | 22 | const AddMembers: FC = ({ conversations }) => { 23 | const { id: conversationId } = useParams(); 24 | const { data, loading, error } = useCollectionQuery( 25 | `all-users-except-${JSON.stringify(conversations.users)}`, 26 | query( 27 | collection(db, "users"), 28 | where("uid", "not-in", conversations.users.slice(0, 10)) 29 | ) 30 | ); 31 | 32 | const handleAddMember = (uid: string) => { 33 | updateDoc(doc(db, "conversations", conversationId as string), { 34 | users: arrayUnion(uid), 35 | }); 36 | }; 37 | 38 | if (loading || error) 39 | return ( 40 |
41 | 42 |
43 | ); 44 | 45 | return ( 46 |
47 | {data?.docs 48 | ?.map((item) => item.data() as SavedUser) 49 | .map((user) => ( 50 |
51 | 56 |
57 |

{user.displayName}

58 |
59 | 62 |
63 | ))} 64 | {data?.empty &&

No more user to add

} 65 |
66 | ); 67 | }; 68 | 69 | export default AddMembers; 70 | -------------------------------------------------------------------------------- /src/components/Group/Admin.tsx: -------------------------------------------------------------------------------- 1 | import { ConversationInfo, SavedUser } from "../../shared/types"; 2 | import { FC, useState } from "react"; 3 | import { arrayRemove, doc, updateDoc } from "firebase/firestore"; 4 | 5 | import { IMAGE_PROXY } from "../../shared/constants"; 6 | import Spin from "react-cssfx-loading/src/Spin"; 7 | import { db } from "../../shared/firebase"; 8 | import { useParams } from "react-router-dom"; 9 | import { useStore } from "../../store"; 10 | import { useUsersInfo } from "../../hooks/useUsersInfo"; 11 | 12 | interface AdminProps { 13 | conversation: ConversationInfo; 14 | } 15 | 16 | const Admin: FC = ({ conversation }) => { 17 | const { id: conversationId } = useParams(); 18 | const currentUser = useStore((state) => state.currentUser); 19 | const { data, loading, error } = useUsersInfo( 20 | conversation.group?.admins as string[] 21 | ); 22 | const handleRemoveAdminPosition = (uid: string) => { 23 | updateDoc(doc(db, "conversations", conversationId as string), { 24 | "group.admins": arrayRemove(uid), 25 | "group.groupImage": conversation.group?.groupImage, 26 | "group.groupName": conversation.group?.groupName, 27 | }); 28 | }; 29 | 30 | if (loading || error) 31 | return ( 32 |
33 | 34 |
35 | ); 36 | 37 | return ( 38 |
39 | { 40 | data 41 | ?.map((item) => item.data() as SavedUser) 42 | .map((user) => ( 43 |
44 | 49 | 50 |
51 |

{user.displayName}

52 |
53 | 54 | {conversation.group?.admins?.includes(currentUser?.uid as string) && 55 | user.uid !== currentUser?.uid && ( 56 |
57 | 60 | 61 |
62 | 69 |
70 |
71 | ) 72 | } 73 |
74 | )) 75 | } 76 |
77 | ); 78 | }; 79 | 80 | export default Admin; 81 | -------------------------------------------------------------------------------- /src/components/Group/Members.tsx: -------------------------------------------------------------------------------- 1 | import { ConversationInfo, SavedUser } from "../../shared/types"; 2 | import { FC, useState } from "react"; 3 | import { arrayRemove, arrayUnion, doc, updateDoc } from "firebase/firestore"; 4 | import { useNavigate, useParams } from "react-router-dom"; 5 | 6 | import Alert from "../Alert"; 7 | import { IMAGE_PROXY } from "../../shared/constants"; 8 | import Spin from "react-cssfx-loading/src/Spin"; 9 | import { db } from "../../shared/firebase"; 10 | import { useStore } from "../../store"; 11 | import { useUsersInfo } from "../../hooks/useUsersInfo"; 12 | 13 | interface MembersProps { 14 | conversation: ConversationInfo; 15 | } 16 | 17 | const Members: FC = ({ conversation }) => { 18 | const { id: conversationId } = useParams(); 19 | const currentUser = useStore((state) => state.currentUser); 20 | const { data, loading, error } = useUsersInfo(conversation.users); 21 | const navigate = useNavigate(); 22 | const [isAlertOpened, setIsAlertOpened] = useState(false); 23 | const [alertText, setAlertText] = useState(""); 24 | const handleRemoveFromGroup = (uid: string) => { 25 | if ( 26 | conversation.group?.admins.length === 1 && 27 | conversation.group.admins[0] === uid 28 | ) { 29 | setAlertText("You must set another one to be an admin"); 30 | setIsAlertOpened(true); 31 | } else { 32 | updateDoc(doc(db, "conversations", conversationId as string), { 33 | users: arrayRemove(uid), 34 | "group.admins": arrayRemove(uid), 35 | "group.groupImage": conversation.group?.groupImage, 36 | "group.groupName": conversation.group?.groupName, 37 | }); 38 | 39 | if (currentUser?.uid === uid) { 40 | navigate("/"); 41 | } 42 | } 43 | }; 44 | 45 | const handleMakeAdmin = (uid: string) => { 46 | updateDoc(doc(db, "conversations", conversationId as string), { 47 | "group.admins": arrayUnion(uid), 48 | "group.groupImage": conversation.group?.groupImage, 49 | "group.groupName": conversation.group?.groupName, 50 | }); 51 | setIsAlertOpened(true); 52 | setAlertText("Done making an admin"); 53 | }; 54 | 55 | if (loading || error) 56 | return ( 57 |
58 | 59 |
60 | ); 61 | 62 | return ( 63 | <> 64 |
65 | {data 66 | ?.map((item) => item.data() as SavedUser) 67 | .map((user) => ( 68 |
69 | 74 | 75 |
76 |

{user.displayName}

77 |
78 | 79 | {conversation.group?.admins?.includes( 80 | currentUser?.uid as string 81 | ) && ( 82 |
83 | 86 | 87 |
88 | {conversation.users.length > 3 && ( 89 | 101 | )} 102 | {user.uid !== currentUser?.uid && ( 103 | 110 | )} 111 |
112 |
113 | )} 114 |
115 | ))} 116 |
117 | 118 | 123 | 124 | ); 125 | }; 126 | 127 | export default Members; 128 | -------------------------------------------------------------------------------- /src/components/Group/ViewGroup.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | 3 | import AddMembers from "./AddMembers"; 4 | import Admin from "./Admin"; 5 | import { ConversationInfo } from "../../shared/types"; 6 | import Members from "./Members"; 7 | 8 | interface ViewGroupProps { 9 | setIsOpened: (value: boolean) => void; 10 | conversation: ConversationInfo; 11 | } 12 | 13 | const ViewGroup: FC = ({ setIsOpened, conversation }) => { 14 | enum Sections { 15 | members, 16 | admins, 17 | addMembers, 18 | } 19 | const [selectedSection, setSelectedSection] = useState(Sections.members); 20 | 21 | return ( 22 |
setIsOpened(false)} 24 | className={`animate-fade-in fixed top-0 left-0 z-20 flex h-full w-full items-center justify-center bg-[#00000080] transition-all duration-300`} 25 | > 26 |
e.stopPropagation()} 28 | className="bg-dark mx-2 w-full max-w-[500px] rounded-lg" 29 | > 30 |
31 |
32 |
33 |

34 | Group Members 35 |

36 |
37 |
38 | 44 |
45 |
46 | 47 |
48 | 56 | 64 | 72 |
73 | 74 | { 75 | selectedSection === Sections.members 76 | ? () : selectedSection === Sections.admins 77 | ? () : selectedSection === Sections.addMembers 78 | ? () : (<>) 79 | } 80 |
81 |
82 | ); 83 | }; 84 | 85 | export default ViewGroup; 86 | -------------------------------------------------------------------------------- /src/components/Home/CreateConversation.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | import { IMAGE_PROXY, THEMES } from "../../shared/constants"; 3 | import { 4 | addDoc, 5 | collection, 6 | getDocs, 7 | query, 8 | serverTimestamp, 9 | where, 10 | } from "firebase/firestore"; 11 | 12 | import Spin from "react-cssfx-loading/src/Spin"; 13 | import { db } from "../../shared/firebase"; 14 | import { useCollectionQuery } from "../../hooks/useCollectionQuery"; 15 | import { useNavigate } from "react-router-dom"; 16 | import { useStore } from "../../store"; 17 | 18 | interface CreateConversationProps { 19 | setIsOpened: (value: boolean) => void; 20 | } 21 | 22 | const CreateConversation: FC = ({ setIsOpened }) => { 23 | const { data, error, loading } = useCollectionQuery( 24 | "all-users", 25 | collection(db, "users") 26 | ); 27 | const [isCreating, setIsCreating] = useState(false); 28 | const currentUser = useStore((state) => state.currentUser); 29 | const [selected, setSelected] = useState([]); 30 | const navigate = useNavigate(); 31 | const handleToggle = (uid: string) => { 32 | if (selected.includes(uid)) { 33 | setSelected(selected.filter((item) => item !== uid)); 34 | } else { 35 | setSelected([...selected, uid]); 36 | } 37 | }; 38 | const handleCreateConversation = async () => { 39 | setIsCreating(true); 40 | 41 | const sorted = [...selected, currentUser?.uid].sort(); 42 | const q = query( 43 | collection(db, "conversations"), 44 | where("users", "==", sorted) 45 | ); 46 | const querySnapshot = await getDocs(q); 47 | 48 | if (querySnapshot.empty) { 49 | const created = await addDoc(collection(db, "conversations"), { 50 | users: sorted, 51 | group: 52 | sorted.length > 2 53 | ? { 54 | admins: [currentUser?.uid], 55 | groupName: null, 56 | groupImage: null, 57 | } 58 | : {}, 59 | updatedAt: serverTimestamp(), 60 | seen: {}, 61 | theme: THEMES[0], 62 | }); 63 | 64 | setIsCreating(false); 65 | setIsOpened(false); 66 | navigate(`/${created.id}`); 67 | } else { 68 | setIsOpened(false); 69 | navigate(`/${querySnapshot.docs[0].id}`); 70 | setIsCreating(false); 71 | } 72 | }; 73 | 74 | return ( 75 |
setIsOpened(false)} 77 | className="fixed top-0 left-0 z-20 flex h-full w-full items-center justify-center bg-[#00000080]" 78 | > 79 |
e.stopPropagation()} 81 | className="bg-dark mx-3 w-full max-w-[500px] overflow-hidden rounded-lg" 82 | > 83 |
84 |
85 |
86 |

87 | New conversation 88 |

89 |
90 |
91 | 97 |
98 |
99 | {loading ? ( 100 |
101 | 102 |
103 | ) : error ? ( 104 |
105 |

Something went wrong

106 |
107 | ) : ( 108 | <> 109 | {isCreating && ( 110 |
111 | 112 |
113 | )} 114 |
115 | {data?.docs 116 | .filter((doc) => doc.data().uid !== currentUser?.uid) 117 | .map((doc) => ( 118 |
handleToggle(doc.data().uid)} 121 | className="hover:bg-dark-lighten flex cursor-pointer items-center gap-2 px-5 py-2 transition" 122 | > 123 | 129 | 134 |

{doc.data().displayName}

135 |
136 | ))} 137 |
138 |
139 | 146 |
147 | 148 | )} 149 |
150 |
151 | ); 152 | }; 153 | 154 | export default CreateConversation; 155 | -------------------------------------------------------------------------------- /src/components/Home/SelectConversation.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useParams } from "react-router-dom"; 2 | 3 | import { ConversationInfo } from "../../shared/types"; 4 | import { FC } from "react"; 5 | import { IMAGE_PROXY } from "../../shared/constants"; 6 | import Skeleton from "../Skeleton"; 7 | import { useLastMessage } from "../../hooks/useLastMessage"; 8 | import { useStore } from "../../store"; 9 | import { useUsersInfo } from "../../hooks/useUsersInfo"; 10 | 11 | interface SelectConversationProps { 12 | conversation: ConversationInfo; 13 | conversationId: string; 14 | } 15 | 16 | const SelectConversation: FC = ({ 17 | conversation, 18 | conversationId, 19 | }) => { 20 | const { data: users, loading } = useUsersInfo(conversation.users); 21 | const currentUser = useStore((state) => state.currentUser); 22 | const filtered = users?.filter((user) => user.id !== currentUser?.uid); 23 | const { id } = useParams(); 24 | const { 25 | data: lastMessage, 26 | loading: lastMessageLoading, 27 | error: lastMessageError, 28 | } = useLastMessage(conversationId); 29 | 30 | if (loading) 31 | return ( 32 |
33 | 34 |
35 | 36 | 37 |
38 |
39 | ); 40 | 41 | if (conversation.users.length === 2) 42 | return ( 43 | 49 | 54 |
55 |

56 | {filtered?.[0].data()?.displayName} 57 |

58 | {lastMessageLoading ? ( 59 | 60 | ) : ( 61 |

62 | {lastMessage?.message} 63 |

64 | )} 65 |
66 | {!lastMessageLoading && ( 67 | <> 68 | { 69 | lastMessage?.lastMessageId !== null && 70 | lastMessage?.lastMessageId !== 71 | conversation.seen[currentUser?.uid as string] && ( 72 |
73 | )} 74 | 75 | )} 76 | 77 | ); 78 | 79 | return ( 80 | 86 | {conversation?.group?.groupImage ? ( 87 | 92 | ) : ( 93 |
94 | 99 | 106 |
107 | )} 108 |
109 |

110 | {conversation?.group?.groupName || filtered?.map((user) => user.data()?.displayName) 111 | .slice(0, 3) 112 | .join(", ")} 113 |

114 | {lastMessageLoading ? ( 115 | 116 | ) : ( 117 |

118 | {lastMessage?.message} 119 |

120 | )} 121 |
122 | {!lastMessageLoading && ( 123 | <> 124 | {lastMessage?.lastMessageId !== null && 125 | lastMessage?.lastMessageId !== 126 | conversation.seen[currentUser?.uid as string] && ( 127 |
128 | ) 129 | } 130 | 131 | )} 132 | 133 | ); 134 | }; 135 | 136 | export default SelectConversation; 137 | -------------------------------------------------------------------------------- /src/components/Home/SideBar.tsx: -------------------------------------------------------------------------------- 1 | import { DEFAULT_AVATAR, IMAGE_PROXY } from "../../shared/constants"; 2 | import { FC, useState } from "react"; 3 | import { Link, useLocation } from "react-router-dom"; 4 | import { collection, orderBy, query, where } from "firebase/firestore"; 5 | 6 | import ClickAwayListener from "../ClickAwayListener"; 7 | import { ConversationInfo } from "../../shared/types"; 8 | import CreateConversation from "./CreateConversation"; 9 | import SelectConversation from "./SelectConversation"; 10 | import Spin from "react-cssfx-loading/src/Spin"; 11 | import UserInfo from "./UserInfo"; 12 | import { auth } from "../../shared/firebase"; 13 | import { db } from "../../shared/firebase"; 14 | import { signOut } from "firebase/auth"; 15 | import { useCollectionQuery } from "../../hooks/useCollectionQuery"; 16 | import { useStore } from "../../store"; 17 | 18 | const SideBar: FC = () => { 19 | const currentUser = useStore((state) => state.currentUser); 20 | const [isDropdownOpened, setIsDropdownOpened] = useState(false); 21 | const [createConversationOpened, setCreateConversationOpened] = useState(false); 22 | const [isUserInfoOpened, setIsUserInfoOpened] = useState(false); 23 | const { data, error, loading } = useCollectionQuery( 24 | "conversations", 25 | query( 26 | collection(db, "conversations"), 27 | orderBy("updatedAt", "desc"), 28 | where("users", "array-contains", currentUser?.uid) 29 | ) 30 | ); 31 | const location = useLocation(); 32 | 33 | return ( 34 | <> 35 |
42 |
43 | 44 | 45 |

FireVerse

46 | 47 | 48 |
49 | 55 | 56 | setIsDropdownOpened(false)}> 57 | {(ref) => ( 58 |
59 | setIsDropdownOpened((prev) => !prev)} 61 | className="h-8 w-8 cursor-pointer rounded-full object-cover" 62 | src={ 63 | currentUser?.photoURL 64 | ? IMAGE_PROXY(currentUser.photoURL) 65 | : DEFAULT_AVATAR 66 | } 67 | alt="" 68 | /> 69 | 70 |
77 | 87 | 94 |
95 |
96 | )} 97 |
98 |
99 |
100 | 101 | {loading ? ( 102 |
103 | 104 |
105 | ) : error ? ( 106 |
107 |

Something went wrong

108 |
109 | ) : data?.empty ? ( 110 |
111 |

No conversation found

112 | 118 |
119 | ) : ( 120 |
121 | {data?.docs.map((item) => ( 122 | 127 | ))} 128 |
129 | )} 130 |
131 | 132 | {createConversationOpened && ( 133 | 134 | )} 135 | 136 | 137 | 138 | ); 139 | }; 140 | 141 | export default SideBar; 142 | -------------------------------------------------------------------------------- /src/components/Home/UserInfo.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { IMAGE_PROXY } from "../../shared/constants"; 3 | import { useStore } from "../../store"; 4 | 5 | interface UserInfoProps { 6 | isOpened: boolean; 7 | setIsOpened: (value: boolean) => void; 8 | } 9 | 10 | const UserInfo: FC = ({ isOpened, setIsOpened }) => { 11 | const currentUser = useStore((state) => state.currentUser); 12 | 13 | return ( 14 |
setIsOpened(false)} 16 | className={`fixed top-0 left-0 z-20 flex h-full w-full items-center justify-center bg-[#00000080] transition-all duration-300 ${ 17 | isOpened ? "visible opacity-100" : "invisible opacity-0" 18 | }`} 19 | > 20 |
e.stopPropagation()} 22 | className="bg-dark mx-2 w-full max-w-[400px] rounded-lg" 23 | > 24 |
25 |
26 |
27 |

28 | Your Profile 29 |

30 |
31 |
32 | 38 |
39 |
40 |
41 |
42 | 47 |
48 |

{currentUser?.displayName}

49 |

ID: {currentUser?.uid}

50 |

Email: {currentUser?.email || "None"}

51 |

Phone Number: {currentUser?.phoneNumber || "None"}

52 |
53 |
54 | 55 |

56 | Change your google / facebook avatar or username to update it here 57 |

58 |
59 |
60 |
61 | ); 62 | }; 63 | 64 | export default UserInfo; 65 | -------------------------------------------------------------------------------- /src/components/Icon/GifIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | const GifIcon: FC<{ className?: string }> = ({ className }) => { 4 | return ( 5 | 14 | 15 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default GifIcon; -------------------------------------------------------------------------------- /src/components/Icon/ReplyIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | const ReplyIcon: FC<{ className?: string }> = ({ className }) => { 4 | return ( 5 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default ReplyIcon; 17 | -------------------------------------------------------------------------------- /src/components/Icon/StickerIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | const StickerIcon: FC<{ className?: string }> = ({ className }) => { 4 | return ( 5 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default StickerIcon; 20 | -------------------------------------------------------------------------------- /src/components/ImageView.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | interface ImageViewProps { 4 | src: string; 5 | isOpened: boolean; 6 | setIsOpened: (value: boolean) => void; 7 | } 8 | 9 | const ImageView: FC = ({ src, isOpened, setIsOpened }) => { 10 | return ( 11 |
setIsOpened(false)} 13 | className={`fixed top-0 left-0 z-20 flex h-full w-full items-center justify-center bg-[#00000080] transition-all duration-300 ${ 14 | isOpened ? "visible opacity-100" : "invisible opacity-0" 15 | }`} 16 | > 17 | {src && ( 18 | e.stopPropagation()} 20 | src={src} 21 | className="h-auto max-h-full w-auto max-w-full" 22 | /> 23 | )} 24 | 25 | 31 |
32 | ); 33 | }; 34 | 35 | export default ImageView; 36 | -------------------------------------------------------------------------------- /src/components/Input/EmojiPicker.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Picker } from "emoji-mart"; 3 | 4 | interface EmojiPickerProps { 5 | onSelect: (emoji: any) => void; 6 | } 7 | 8 | const EmojiPicker: FC = ({ onSelect }) => { 9 | return ( 10 | 21 | ); 22 | }; 23 | 24 | export default EmojiPicker; 25 | -------------------------------------------------------------------------------- /src/components/Input/GifPicker.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useRef, useState } from "react"; 2 | 3 | import ClickAwayListener from "../ClickAwayListener"; 4 | import Spin from "react-cssfx-loading/src/Spin"; 5 | import configs from "../../shared/configs"; 6 | import { useFetch } from "../../hooks/useFetch"; 7 | 8 | interface GifPickerProps { 9 | setIsOpened: (value: boolean) => void; 10 | onSelect: (gif: any) => void; 11 | } 12 | 13 | const GifPicker: FC = ({ setIsOpened, onSelect }) => { 14 | const [searchInputValue, setSearchInputValue] = useState(""); 15 | const timeOutRef = useRef(null); 16 | const { data, loading, error } = useFetch(`giphy-${searchInputValue}`, () => 17 | fetch( 18 | searchInputValue.trim() 19 | ? `https://api.giphy.com/v1/gifs/search?api_key=${ 20 | configs.giphyAPIKey 21 | }&q=${encodeURIComponent(searchInputValue.trim())}` 22 | : `https://api.giphy.com/v1/gifs/trending?api_key=${configs.giphyAPIKey}` 23 | ).then((res) => res.json()) 24 | ); 25 | 26 | return ( 27 | setIsOpened(false)}> 28 | {(ref) => ( 29 |
33 |
34 | { 36 | if (timeOutRef.current) clearTimeout(timeOutRef.current); 37 | timeOutRef.current = setTimeout(() => { 38 | setSearchInputValue(e.target.value); 39 | }, 500); 40 | }} 41 | type="text" 42 | className="bg-dark-lighten w-full rounded-full py-2 pl-10 pr-4 outline-none" 43 | placeholder="Search..." 44 | /> 45 | 46 |
47 | 48 | {loading ? ( 49 |
50 | 51 |
52 | ) : error ? ( 53 |
54 |

55 | Sorry... Giphy has limited the request 56 |

57 |
58 | ) : ( 59 |
60 | {(data as any).data.map((item: any) => ( 61 | { 64 | onSelect(item?.images?.original?.url); 65 | setIsOpened(false); 66 | }} 67 | className="h-[100px] flex-1 cursor-pointer object-cover" 68 | src={item?.images?.original?.url} 69 | alt="" 70 | /> 71 | ))} 72 |
73 | )} 74 |
75 | )} 76 |
77 | ); 78 | }; 79 | 80 | export default GifPicker; 81 | -------------------------------------------------------------------------------- /src/components/Input/StickerPicker.tsx: -------------------------------------------------------------------------------- 1 | import { FC, Fragment, useState } from "react"; 2 | 3 | import ClickAwayListener from "../ClickAwayListener"; 4 | import { STICKERS_URL } from "../../shared/constants"; 5 | import Spin from "react-cssfx-loading/src/Spin"; 6 | import SpriteRenderer from "../SpriteRenderer"; 7 | import { StickerCollections } from "../../shared/types"; 8 | import { useFetch } from "../../hooks/useFetch"; 9 | 10 | interface StickerPickerOpened { 11 | setIsOpened: (value: boolean) => void; 12 | onSelect: (value: string) => void; 13 | } 14 | 15 | const getRecentStickers = () => { 16 | const existing = localStorage.getItem("fireverse-recent-stickers") || "[]"; 17 | 18 | try { 19 | const parsed = JSON.parse(existing); 20 | if (Array.isArray(parsed)) return parsed; 21 | return []; 22 | } catch (error) { 23 | return []; 24 | } 25 | }; 26 | 27 | const StickerPicker: FC = ({ setIsOpened, onSelect }) => { 28 | const { data, loading, error } = useFetch("sticker", () => 29 | fetch(STICKERS_URL).then((res) => res.json()) 30 | ); 31 | const [recentStickers, setRecentStickers] = useState(getRecentStickers()); 32 | const addRecentSticker = (url: string) => { 33 | const added = [...new Set([url, ...recentStickers])]; 34 | 35 | localStorage.setItem("fireverse-recent-stickers", JSON.stringify(added)); 36 | setRecentStickers(added); 37 | }; 38 | 39 | return ( 40 | setIsOpened(false)}> 41 | {(ref) => ( 42 |
46 | {loading || error ? ( 47 |
48 | 49 |
50 | ) : ( 51 |
52 |
53 | {recentStickers.length > 0 && ( 54 | <> 55 |

56 | Recent stickers 57 |

58 |
59 | {recentStickers.map((url) => ( 60 | { 63 | onSelect(url); 64 | addRecentSticker(url); 65 | setIsOpened(false); 66 | }} 67 | className="hover:bg-dark-lighten cursor-pointer" 68 | src={url} 69 | runOnHover 70 | /> 71 | ))} 72 |
73 | 74 | )} 75 | 76 | {data?.map((collection) => ( 77 | 78 |

79 | {collection.name} 80 |

81 |
82 | {collection.stickers.map((sticker) => ( 83 | { 87 | onSelect(sticker.spriteURL); 88 | addRecentSticker(sticker.spriteURL); 89 | setIsOpened(false); 90 | }} 91 | className="hover:bg-dark-lighten cursor-pointer" 92 | src={sticker.spriteURL} 93 | runOnHover 94 | /> 95 | ))} 96 |
97 |
98 | ))} 99 |
100 | 101 |
102 | {recentStickers.length > 0 && ( 103 | 113 | )} 114 | {data?.map((collection) => ( 115 | 117 | document 118 | .querySelector(`#sticker-${collection.id}`) 119 | ?.scrollIntoView() 120 | } 121 | className="h-9 w-9 cursor-pointer object-cover" 122 | src={collection.icon} 123 | alt="" 124 | /> 125 | ))} 126 |
127 |
128 | )} 129 |
130 | )} 131 |
132 | ); 133 | }; 134 | 135 | export default StickerPicker; 136 | -------------------------------------------------------------------------------- /src/components/Media/Files.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | import { collection, orderBy, query, where } from "firebase/firestore"; 3 | 4 | import FileIcon from "../FileIcon"; 5 | import Spin from "react-cssfx-loading/src/Spin"; 6 | import { db } from "../../shared/firebase"; 7 | import { formatFileSize } from "../../shared/utils"; 8 | import { useCollectionQuery } from "../../hooks/useCollectionQuery"; 9 | import { useParams } from "react-router-dom"; 10 | 11 | const Files: FC = () => { 12 | const { id: conversationId } = useParams(); 13 | const { data, loading, error } = useCollectionQuery( 14 | `files-${conversationId}`, 15 | query( 16 | collection(db, "conversations", conversationId as string, "messages"), 17 | where("type", "==", "file"), 18 | orderBy("createdAt", "desc") 19 | ) 20 | ); 21 | 22 | if (loading || error) 23 | return ( 24 |
25 | 26 |
27 | ); 28 | 29 | if (data?.empty) 30 | return ( 31 |
32 |

No file found

33 |
34 | ); 35 | 36 | return ( 37 |
38 | {data?.docs.map((file) => ( 39 |
40 | 44 |
45 |

{file.data()?.file?.name}

46 |

{formatFileSize(file.data()?.file?.size)}

47 |
48 | 54 | 55 | 56 |
57 | ))} 58 |
59 | ); 60 | }; 61 | 62 | export default Files; 63 | -------------------------------------------------------------------------------- /src/components/Media/Image.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | import { collection, orderBy, query, where } from "firebase/firestore"; 3 | 4 | import ImageView from "../ImageView"; 5 | import Spin from "react-cssfx-loading/src/Spin"; 6 | import { db } from "../../shared/firebase"; 7 | import { useCollectionQuery } from "../../hooks/useCollectionQuery"; 8 | import { useParams } from "react-router-dom"; 9 | 10 | const ImageItem: FC<{ src: string }> = ({ src }) => { 11 | const [isImageViewOpened, setIsImageViewOpened] = useState(false); 12 | 13 | return ( 14 | <> 15 | setIsImageViewOpened(true)} 17 | className="h-[100px] w-[100px] cursor-pointer object-cover transition duration-300 hover:brightness-75" 18 | src={src} 19 | alt="" 20 | /> 21 | 26 | 27 | ); 28 | }; 29 | 30 | const Image: FC = () => { 31 | const { id: conversationId } = useParams(); 32 | 33 | const { data, loading, error } = useCollectionQuery( 34 | `images-${conversationId}`, 35 | query( 36 | collection(db, "conversations", conversationId as string, "messages"), 37 | where("type", "==", "image"), 38 | orderBy("createdAt", "desc") 39 | ) 40 | ); 41 | 42 | if (loading || error) 43 | return ( 44 |
45 | 46 |
47 | ); 48 | 49 | if (data?.empty) 50 | return ( 51 |
52 |

No image found

53 |
54 | ); 55 | 56 | return ( 57 |
58 | {data?.docs.map((image) => ( 59 | 60 | ))} 61 |
62 | ); 63 | }; 64 | 65 | export default Image; 66 | -------------------------------------------------------------------------------- /src/components/Media/ViewMedia.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | 3 | import Files from "./Files"; 4 | import Image from "./Image"; 5 | 6 | interface ViewMediaProps { 7 | setIsOpened: (value: boolean) => void; 8 | } 9 | 10 | const ViewMedia: FC = ({ setIsOpened }) => { 11 | enum Sections { 12 | images, 13 | files, 14 | } 15 | const [selectedSection, setSelectedSection] = useState(Sections.images); 16 | 17 | return ( 18 |
setIsOpened(false)} 20 | className={`animate-fade-in fixed top-0 left-0 z-20 flex h-full w-full items-center justify-center bg-[#00000080] transition-all duration-300`} 21 | > 22 |
e.stopPropagation()} 24 | className="bg-dark mx-2 w-full max-w-[500px] rounded-lg" 25 | > 26 |
27 |
28 |
29 |

30 | View images and files 31 |

32 |
33 |
34 | 40 |
41 |
42 | 43 |
44 | 52 | 60 |
61 | 62 | { 63 | selectedSection === Sections.images ? () : selectedSection === Sections.files 64 | ? () : (<>) 65 | } 66 |
67 |
68 | ); 69 | }; 70 | 71 | export default ViewMedia; 72 | -------------------------------------------------------------------------------- /src/components/Message/LeftMessage.tsx: -------------------------------------------------------------------------------- 1 | import { ConversationInfo, MessageItem } from "../../shared/types"; 2 | import { FC, Fragment, useState } from "react"; 3 | import { 4 | formatDate, 5 | formatFileSize, 6 | splitLinkFromMessage, 7 | } from "../../shared/utils"; 8 | 9 | import AvatarFromId from "../Chat/AvatarFromId"; 10 | import ClickAwayListener from "../ClickAwayListener"; 11 | import { EMOJI_REGEX } from "../../shared/constants"; 12 | import FileIcon from "../FileIcon"; 13 | import ImageView from "../ImageView"; 14 | import ReactionPopup from "../Chat/ReactionPopup"; 15 | import ReactionStatus from "../Chat/ReactionStatus"; 16 | import ReplyBadge from "../Chat/ReplyBadge"; 17 | import ReplyIcon from "../Icon/ReplyIcon"; 18 | import SpriteRenderer from "../SpriteRenderer"; 19 | import { useStore } from "../../store"; 20 | 21 | interface LeftMessageProps { 22 | message: MessageItem; 23 | conversation: ConversationInfo; 24 | index: number; 25 | docs: any[]; 26 | replyInfo: any; 27 | setReplyInfo: (value: any) => void; 28 | } 29 | 30 | const LeftMessage: FC = ({ 31 | message, 32 | conversation, 33 | index, 34 | docs, 35 | setReplyInfo, 36 | }) => { 37 | const [isSelectReactionOpened, setIsSelectReactionOpened] = useState(false); 38 | const currentUser = useStore((state) => state.currentUser); 39 | 40 | const [isImageViewOpened, setIsImageViewOpened] = useState(false); 41 | 42 | const formattedDate = formatDate( 43 | message.createdAt.seconds ? message.createdAt.seconds * 1000 : Date.now() 44 | ); 45 | 46 | return ( 47 |
48 |
53 | {!!message.replyTo && ( 54 | 55 | )} 56 |
57 |
{ 59 | if (e.detail === 2 && message.type !== "removed") { 60 | setReplyInfo(message); 61 | } 62 | }} 63 | className={`group relative flex items-stretch gap-2 px-8 ${ 64 | Object.keys(message.reactions || {}).length > 0 ? "mb-2" : "" 65 | }`} 66 | > 67 | {conversation.users.length > 2 && ( 68 |
e.stopPropagation()} className="h-full py-1"> 69 |
70 | {docs[index - 1]?.data()?.sender !== message.sender && ( 71 | 72 | )} 73 |
74 |
75 | )} 76 | 77 | {message.type === "text" ? ( 78 | <> 79 | {EMOJI_REGEX.test(message.content) ? ( 80 |
e.stopPropagation()} 82 | title={formattedDate} 83 | className="text-4xl" 84 | > 85 | {message.content} 86 |
87 | ) : ( 88 |
e.stopPropagation()} 90 | title={formattedDate} 91 | className={`bg-dark-lighten rounded-lg p-2 text-white ${ 92 | conversation.users.length === 2 93 | ? "after:border-dark-lighten relative after:absolute after:right-full after:bottom-[6px] after:border-8 after:border-t-transparent after:border-l-transparent" 94 | : "" 95 | }`} 96 | > 97 | {splitLinkFromMessage(message.content).map((item, index) => ( 98 | 99 | {typeof item === "string" ? ( 100 | {item} 101 | ) : ( 102 | 108 | {item.link} 109 | 110 | )} 111 | 112 | ))} 113 |
114 | )} 115 | 116 | ) : message.type === "image" ? ( 117 | <> 118 | { 120 | setIsImageViewOpened(true); 121 | e.stopPropagation(); 122 | }} 123 | title={formattedDate} 124 | className="max-w-[60%] cursor-pointer transition duration-300 hover:brightness-[85%]" 125 | src={message.content} 126 | alt="" 127 | /> 128 | 133 | 134 | ) : message.type === "file" ? ( 135 |
e.stopPropagation()} 137 | title={formattedDate} 138 | className="bg-dark-lighten flex items-center gap-2 overflow-hidden rounded-lg py-3 px-5" 139 | > 140 | 144 |
145 |

146 | {message.file?.name} 147 |

148 | 149 |

150 | {formatFileSize(message.file?.size as number)} 151 |

152 |
153 | 154 | 160 | 161 | 162 |
163 | ) : message.type === "sticker" ? ( 164 | e.stopPropagation()} 166 | title={formattedDate} 167 | src={message.content} 168 | size={130} 169 | /> 170 | ) : ( 171 |
e.stopPropagation()} 173 | title={formattedDate} 174 | className="border-dark-lighten rounded-lg border p-3 text-gray-400" 175 | > 176 | Message has been removed 177 |
178 | )} 179 | 180 | {message.type !== "removed" && ( 181 | <> 182 | 188 | 197 | 198 | {isSelectReactionOpened && ( 199 | setIsSelectReactionOpened(false)} 201 | > 202 | {(ref) => ( 203 | 212 | )} 213 | 214 | )} 215 | 216 | )} 217 | {Object.keys(message.reactions || {}).length > 0 && ( 218 | 2 ? "left-tab" : "left"} 221 | /> 222 | )} 223 |
224 |
225 | ); 226 | }; 227 | 228 | export default LeftMessage; 229 | -------------------------------------------------------------------------------- /src/components/Message/RightMessage.tsx: -------------------------------------------------------------------------------- 1 | import { FC, Fragment, useState } from "react"; 2 | import { doc, updateDoc } from "firebase/firestore"; 3 | import { 4 | formatDate, 5 | formatFileSize, 6 | splitLinkFromMessage, 7 | } from "../../shared/utils"; 8 | 9 | import ClickAwayListener from "../ClickAwayListener"; 10 | import { EMOJI_REGEX } from "../../shared/constants"; 11 | import FileIcon from "../FileIcon"; 12 | import ImageView from "../ImageView"; 13 | import { MessageItem } from "../../shared/types"; 14 | import ReactionPopup from "../Chat/ReactionPopup"; 15 | import ReactionStatus from "../Chat/ReactionStatus"; 16 | import ReplyBadge from "../Chat/ReplyBadge"; 17 | import ReplyIcon from "../Icon/ReplyIcon"; 18 | import SpriteRenderer from "../SpriteRenderer"; 19 | import { db } from "../../shared/firebase"; 20 | import { useParams } from "react-router-dom"; 21 | import { useStore } from "../../store"; 22 | 23 | interface RightMessageProps { 24 | message: MessageItem; 25 | replyInfo: any; 26 | setReplyInfo: (value: any) => void; 27 | } 28 | 29 | const RightMessage: FC = ({ message, setReplyInfo }) => { 30 | const [isSelectReactionOpened, setIsSelectReactionOpened] = useState(false); 31 | const { id: conversationId } = useParams(); 32 | const currentUser = useStore((state) => state.currentUser); 33 | const [isImageViewOpened, setIsImageViewOpened] = useState(false); 34 | const removeMessage = (messageId: string) => { 35 | updateDoc( 36 | doc(db, "conversations", conversationId as string, "messages", messageId),{ 37 | type: "removed", 38 | file: null, 39 | content: "", 40 | reactions: [], 41 | } 42 | ); 43 | }; 44 | const formattedDate = formatDate( 45 | message.createdAt?.seconds ? message.createdAt?.seconds * 1000 : Date.now() 46 | ); 47 | 48 | return ( 49 |
50 |
51 | {!!message.replyTo && ( 52 | 53 | )} 54 |
55 |
{ 57 | if (e.detail === 2 && message.type !== "removed") { 58 | setReplyInfo(message); 59 | } 60 | }} 61 | className={`group relative flex flex-row-reverse items-stretch gap-2 px-8 ${ 62 | Object.keys(message.reactions || {}).length > 0 ? "mb-2" : "" 63 | }`} 64 | > 65 | {message.type === "text" ? ( 66 | <> 67 | {EMOJI_REGEX.test(message.content) ? ( 68 |
e.stopPropagation()} 70 | title={formattedDate} 71 | className="text-4xl" 72 | > 73 | {message.content} 74 |
75 | ) : ( 76 |
e.stopPropagation()} 78 | title={formattedDate} 79 | className={`bg-primary after:border-primary relative rounded-lg p-2 text-white after:absolute after:left-full after:bottom-[6px] after:border-8 after:border-t-transparent after:border-r-transparent`} 80 | > 81 | {splitLinkFromMessage(message.content).map((item, index) => ( 82 | 83 | {typeof item === "string" ? ( 84 | {item} 85 | ) : ( 86 | 92 | {item.link} 93 | 94 | )} 95 | 96 | ))} 97 |
98 | )} 99 | 100 | ) : message.type === "image" ? ( 101 | <> 102 | { 104 | setIsImageViewOpened(true); 105 | e.stopPropagation(); 106 | }} 107 | title={formattedDate} 108 | className="max-w-[60%] cursor-pointer transition duration-300 hover:brightness-[85%]" 109 | src={message.content} 110 | alt="" 111 | /> 112 | 117 | 118 | ) : message.type === "file" ? ( 119 |
e.stopPropagation()} 121 | title={formattedDate} 122 | className="bg-dark-lighten flex items-center gap-2 overflow-hidden rounded-lg py-3 px-5" 123 | > 124 | 128 | 129 |
130 |

131 | {message.file?.name} 132 |

133 | 134 |

135 | {formatFileSize(message.file?.size as number)} 136 |

137 |
138 | 139 | 145 | 146 | 147 |
148 | ) : message.type === "sticker" ? ( 149 | e.stopPropagation()} 151 | title={formattedDate} 152 | src={message.content} 153 | size={130} 154 | /> 155 | ) : ( 156 |
e.stopPropagation()} 158 | title={formattedDate} 159 | className="border-dark-lighten rounded-lg border p-3 text-gray-400" 160 | > 161 | Message has been removed 162 |
163 | )} 164 | 165 | {message.type !== "removed" && ( 166 | <> 167 | 173 | 174 | 183 | 184 | 193 | 194 | {isSelectReactionOpened && ( 195 | setIsSelectReactionOpened(false)} 197 | > 198 | {(ref) => ( 199 | 208 | )} 209 | 210 | )} 211 | 212 | {Object.keys(message.reactions || {}).length > 0 && ( 213 | 214 | )} 215 | 216 | )} 217 |
218 |
219 | ); 220 | }; 221 | 222 | export default RightMessage; 223 | -------------------------------------------------------------------------------- /src/components/PrivateRoute.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate, useLocation } from "react-router-dom"; 2 | 3 | import { FC } from "react"; 4 | import { useStore } from "../store"; 5 | 6 | const PrivateRoute: FC = ({ children }) => { 7 | const currentUser = useStore((state) => state.currentUser); 8 | const location = useLocation(); 9 | 10 | if (!currentUser) 11 | return ( 12 | 17 | ); 18 | 19 | return <>{children}; 20 | }; 21 | 22 | export default PrivateRoute; 23 | -------------------------------------------------------------------------------- /src/components/Skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { FC, HTMLProps } from "react"; 2 | 3 | const Skeleton: FC> = ({ className, ...others }) => { 4 | return ( 5 |
6 | ); 7 | }; 8 | 9 | export default Skeleton; 10 | -------------------------------------------------------------------------------- /src/components/SpriteRenderer.tsx: -------------------------------------------------------------------------------- 1 | import { FC, HTMLProps, useEffect, useRef, useState } from "react"; 2 | 3 | interface SpriteRendererProps { 4 | src: string; 5 | size: number; 6 | runOnHover?: boolean; 7 | delay?: number; 8 | } 9 | 10 | const SpriteRenderer: FC & SpriteRendererProps> = ({ 11 | src, 12 | size, 13 | runOnHover = false, 14 | delay = 100, 15 | ...others 16 | }) => { 17 | const containerRef = useRef(null); 18 | 19 | const [loaded, setLoaded] = useState(false); 20 | const intervalId = useRef(null); 21 | 22 | useEffect(() => { 23 | setLoaded(false); 24 | 25 | const img = new Image(); 26 | img.src = src; 27 | 28 | img.addEventListener( 29 | "load", 30 | () => { 31 | setLoaded(true); 32 | 33 | let stepCount = Math.ceil(img.width / img.height); 34 | 35 | let count = 0; 36 | 37 | if (runOnHover) { 38 | containerRef.current?.addEventListener("mouseenter", () => { 39 | intervalId.current = setInterval(() => { 40 | if (!containerRef.current) clearInterval(intervalId.current); 41 | 42 | containerRef.current && 43 | (containerRef.current.style.backgroundPosition = `${Math.round( 44 | -((count % stepCount) + 1) * size 45 | )}px 50%`); 46 | 47 | count++; 48 | }, delay); 49 | }); 50 | 51 | containerRef.current?.addEventListener("mouseleave", () => { 52 | if (intervalId.current) clearInterval(intervalId.current); 53 | 54 | count = 0; 55 | 56 | containerRef.current && 57 | (containerRef.current.style.backgroundPosition = "0px 50%"); 58 | }); 59 | } else { 60 | intervalId.current = setInterval(() => { 61 | if (!containerRef.current) clearInterval(intervalId.current); 62 | 63 | containerRef.current && 64 | (containerRef.current.style.backgroundPosition = `${Math.round( 65 | -((count % stepCount) + 1) * size 66 | )}px 50%`); 67 | 68 | count++; 69 | }, delay); 70 | } 71 | }, 72 | { once: true } 73 | ); 74 | 75 | return () => { 76 | if (intervalId.current) clearInterval(intervalId.current); 77 | }; 78 | }, []); 79 | 80 | return ( 81 |
92 | ); 93 | }; 94 | 95 | export default SpriteRenderer; 96 | -------------------------------------------------------------------------------- /src/hooks/useCollectionQuery.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CollectionReference, 3 | DocumentData, 4 | Query, 5 | QuerySnapshot, 6 | onSnapshot, 7 | } from "firebase/firestore"; 8 | import { useEffect, useState } from "react"; 9 | 10 | let cache: { [key: string]: any } = {}; 11 | 12 | export const useCollectionQuery: ( 13 | key: string, 14 | collection: CollectionReference | Query 15 | ) => { loading: boolean; error: boolean; data: QuerySnapshot | null } = ( 16 | key, 17 | collection 18 | ) => { 19 | const [data, setData] = useState | null>( 20 | cache[key] || null 21 | ); 22 | 23 | const [loading, setLoading] = useState(!data); 24 | const [error, setError] = useState(false); 25 | 26 | useEffect(() => { 27 | const unsubscribe = onSnapshot( 28 | collection, 29 | (snapshot) => { 30 | setData(snapshot); 31 | setLoading(false); 32 | setError(false); 33 | cache[key] = snapshot; 34 | }, 35 | (err) => { 36 | console.log(err); 37 | setData(null); 38 | setLoading(false); 39 | setError(true); 40 | } 41 | ); 42 | 43 | return () => { 44 | unsubscribe(); 45 | }; 46 | 47 | // eslint-disable-next-line 48 | }, [key]); 49 | 50 | return { loading, error, data }; 51 | }; -------------------------------------------------------------------------------- /src/hooks/useDocumentQuery.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DocumentData, 3 | DocumentReference, 4 | DocumentSnapshot, 5 | onSnapshot, 6 | } from "firebase/firestore"; 7 | import { useEffect, useState } from "react"; 8 | 9 | let cache: { [key: string]: any } = {}; 10 | 11 | export const useDocumentQuery = ( 12 | key: string, 13 | document: DocumentReference 14 | ) => { 15 | const [data, setData] = useState | null>( 16 | cache[key] || null 17 | ); 18 | const [loading, setLoading] = useState(!Boolean(data)); 19 | const [error, setError] = useState(false); 20 | 21 | useEffect(() => { 22 | const unsubscribe = onSnapshot( 23 | document, 24 | (snapshot) => { 25 | setData(snapshot); 26 | setLoading(false); 27 | }, 28 | (err) => { 29 | console.log(err); 30 | setData(null); 31 | setLoading(false); 32 | setError(true); 33 | } 34 | ); 35 | 36 | return () => { 37 | unsubscribe(); 38 | }; 39 | // eslint-disable-next-line 40 | }, [key]); 41 | 42 | return { loading, error, data }; 43 | }; 44 | -------------------------------------------------------------------------------- /src/hooks/useFetch.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | let cache: { [key: string]: any } = {}; 4 | 5 | export function useFetch( 6 | key: string, 7 | query: (...arg: any) => Promise 8 | ): { data: T | null; loading: boolean; error: boolean } { 9 | const [data, setData] = useState(cache[key] || null); 10 | const [loading, setLoading] = useState(!data); 11 | const [error, setError] = useState(false); 12 | 13 | useEffect(() => { 14 | query() 15 | .then((res) => { 16 | cache[key] = res; 17 | setData(res); 18 | setLoading(false); 19 | setError(false); 20 | }) 21 | .catch((err) => { 22 | console.log(err); 23 | setData(null); 24 | setLoading(false); 25 | setError(true); 26 | }); 27 | }, [key]); 28 | 29 | return { data, loading, error }; 30 | } -------------------------------------------------------------------------------- /src/hooks/useLastMessage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | collection, 3 | limitToLast, 4 | onSnapshot, 5 | orderBy, 6 | query, 7 | } from "firebase/firestore"; 8 | import { useEffect, useState } from "react"; 9 | 10 | import { db } from "../shared/firebase"; 11 | import { formatDate } from "../shared/utils"; 12 | 13 | let cache: { [key: string]: any } = {}; 14 | 15 | export const useLastMessage = (conversationId: string) => { 16 | const [data, setData] = useState<{ 17 | lastMessageId: string | null; 18 | message: string; 19 | } | null>(cache[conversationId] || null); 20 | const [loading, setLoading] = useState(!data); 21 | const [error, setError] = useState(false); 22 | 23 | useEffect(() => { 24 | const unsubscribe = onSnapshot( 25 | query( 26 | collection(db, "conversations", conversationId, "messages"), 27 | orderBy("createdAt"), 28 | limitToLast(1) 29 | ), 30 | (snapshot) => { 31 | if (snapshot.empty) { 32 | setData({ 33 | lastMessageId: null, 34 | message: "No message recently", 35 | }); 36 | setLoading(false); 37 | setError(false); 38 | 39 | return; 40 | } 41 | 42 | const type = snapshot.docs?.[0]?.data()?.type; 43 | 44 | let response = 45 | type === "image" 46 | ? "An image" : type === "file" 47 | ? `File: ${ 48 | snapshot.docs[0]?.data()?.file?.name.split(".").slice(-1)[0] 49 | }` : type === "sticker" 50 | ? "A sticker" : type === "removed" 51 | ? "Message removed" : (snapshot.docs[0].data().content as string); 52 | 53 | const seconds = snapshot.docs[0]?.data()?.createdAt?.seconds; 54 | const formattedDate = formatDate(seconds ? seconds * 1000 : Date.now()); 55 | 56 | response = 57 | response.length > 30 - formattedDate.length 58 | ? `${response.slice(0, 30 - formattedDate.length)}...` 59 | : response; 60 | 61 | const result = `${response} • ${formattedDate}`; 62 | 63 | setData({ 64 | lastMessageId: snapshot.docs?.[0]?.id, 65 | message: result, 66 | }); 67 | 68 | cache[conversationId] = { 69 | lastMessageId: snapshot.docs?.[0]?.id, 70 | message: result, 71 | }; 72 | 73 | setLoading(false); 74 | setError(false); 75 | }, 76 | (err) => { 77 | console.log(err); 78 | setData(null); 79 | setLoading(false); 80 | setError(true); 81 | } 82 | ); 83 | 84 | return () => { 85 | unsubscribe(); 86 | }; 87 | }, [conversationId]); 88 | 89 | return { data, loading, error }; 90 | }; 91 | -------------------------------------------------------------------------------- /src/hooks/useQueryParams.ts: -------------------------------------------------------------------------------- 1 | import { useLocation } from "react-router-dom"; 2 | 3 | export const useQueryParams = () => { 4 | const location = useLocation(); 5 | const searchParams = Object.fromEntries( 6 | new URLSearchParams(location.search).entries() 7 | ); 8 | 9 | return searchParams; 10 | }; 11 | -------------------------------------------------------------------------------- /src/hooks/useUsersInfo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DocumentData, 3 | DocumentSnapshot, 4 | doc, 5 | getDoc, 6 | } from "firebase/firestore"; 7 | import { useEffect, useState } from "react"; 8 | 9 | import { db } from "../shared/firebase"; 10 | 11 | let cache: { [key: string]: any } = {}; 12 | 13 | export const useUsersInfo = (userIds: string[]) => { 14 | const [data, setData] = useState[] | null>( 15 | userIds.every((id) => cache[id]) ? userIds.map((id) => cache[id]) : null 16 | ); 17 | const [loading, setLoading] = useState(!data); 18 | const [error, setError] = useState(false); 19 | 20 | useEffect(() => { 21 | try { 22 | (async () => { 23 | const response = await Promise.all( 24 | userIds.map(async (id) => { 25 | if (cache[id]) return cache[id]; 26 | 27 | const res = await getDoc(doc(db, "users", id)); 28 | cache[id] = res; 29 | 30 | return res; 31 | }) 32 | ); 33 | 34 | setData(response); 35 | setLoading(false); 36 | setError(false); 37 | })(); 38 | } catch (error) { 39 | console.log(error); 40 | setLoading(false); 41 | setError(true); 42 | } 43 | }, [JSON.stringify(userIds)]); 44 | 45 | return { data, loading, error }; 46 | }; 47 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import "./styles/index.css"; 2 | import "emoji-mart/css/emoji-mart.css"; 3 | 4 | import App from "./App"; 5 | import { BrowserRouter } from "react-router-dom"; 6 | import ReactDOM from "react-dom"; 7 | 8 | ReactDOM.render( 9 | 10 | 11 | , 12 | document.getElementById("root") 13 | ); -------------------------------------------------------------------------------- /src/pages/Chat.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from "react"; 2 | 3 | import ChatHeader from "../components/Chat/ChatHeader"; 4 | import ChatView from "../components/Chat/ChatView"; 5 | import { ConversationInfo } from "../shared/types"; 6 | import InputSection from "../components/Input/InputSection"; 7 | import SideBar from "../components/Home/SideBar"; 8 | import { db } from "../shared/firebase"; 9 | import { doc } from "firebase/firestore"; 10 | import { useDocumentQuery } from "../hooks/useDocumentQuery"; 11 | import { useParams } from "react-router-dom"; 12 | import { useStore } from "../store"; 13 | 14 | const Chat: FC = () => { 15 | const { id } = useParams(); 16 | 17 | const { data, loading, error } = useDocumentQuery( 18 | `conversation-${id}`, 19 | doc(db, "conversations", id as string) 20 | ); 21 | 22 | const conversation = data?.data() as ConversationInfo; 23 | 24 | const currentUser = useStore((state) => state.currentUser); 25 | 26 | const [inputSectionOffset, setInputSectionOffset] = useState(0); 27 | 28 | const [replyInfo, setReplyInfo] = useState(null); 29 | 30 | useEffect(() => { 31 | if (conversation?.theme) 32 | document.body.style.setProperty("--primary-color", conversation.theme); 33 | }, [conversation?.theme || ""]); 34 | 35 | return ( 36 |
37 | 38 | 39 |
40 | {loading ? ( 41 | <> 42 |
43 |
44 | 45 | 46 | ) : !conversation || error || !conversation.users.includes(currentUser?.uid as string) ? ( 47 |
48 | 49 |

Conversation does not exists

50 |
51 | ) : ( 52 | <> 53 | 54 | 60 | 66 | 67 | )} 68 |
69 |
70 | ); 71 | }; 72 | 73 | export default Chat; -------------------------------------------------------------------------------- /src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import SideBar from "../components/Home/SideBar"; 3 | 4 | const Home: FC = () => { 5 | return ( 6 |
7 | 8 | 9 |
10 |

Select a conversation to start chatting

11 |
12 |
13 | ); 14 | }; 15 | 16 | export default Home; 17 | -------------------------------------------------------------------------------- /src/pages/SignIn.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AuthProvider, 3 | FacebookAuthProvider, 4 | GoogleAuthProvider, 5 | signInWithPopup, 6 | } from "firebase/auth"; 7 | import { FC, useState } from "react"; 8 | 9 | import Alert from "../components/Alert"; 10 | import { Navigate } from "react-router-dom"; 11 | import { auth } from "../shared/firebase"; 12 | import { useQueryParams } from "../hooks/useQueryParams"; 13 | import { useStore } from "../store"; 14 | 15 | const SignIn: FC = () => { 16 | const { redirect } = useQueryParams(); 17 | 18 | const currentUser = useStore((state) => state.currentUser); 19 | 20 | const [loading, setLoading] = useState(false); 21 | const [error, setError] = useState(""); 22 | const [isAlertOpened, setIsAlertOpened] = useState(false); 23 | 24 | const handleSignIn = (provider: AuthProvider) => { 25 | 26 | setLoading(true); 27 | 28 | signInWithPopup(auth, provider) 29 | .then((res) => { 30 | console.log(res.user); 31 | }) 32 | .catch((err) => { 33 | setIsAlertOpened(true); 34 | setError(`Error: ${err.code}`); 35 | }) 36 | .finally(() => { 37 | setLoading(false); 38 | }); 39 | }; 40 | 41 | if (currentUser) return ; 42 | 43 | return ( 44 | <> 45 |
46 |
47 |
48 |
49 | 50 | FireVerse 51 |
52 | 58 | 59 | Github 60 | 61 |
62 | 63 |
64 |
65 | 66 |
67 | 68 |
69 |

70 | The best place for messaging 71 |

72 |

73 | It's free, fast and secure. We make it easy and fun to stay 74 | close to your favourite people. 75 |

76 | 77 | 85 | 86 | 94 |
95 |
96 |
97 |
98 | 99 | 105 | 106 | ); 107 | }; 108 | 109 | export default SignIn; 110 | -------------------------------------------------------------------------------- /src/shared/configs.ts: -------------------------------------------------------------------------------- 1 | const configs = { 2 | apiKey: "AIzaSyDwiB94NJfBOzUJpqyJBV2b9Fr9wO6TQho", 3 | authDomain: "react-typesctipt-firebase.firebaseapp.com", 4 | databaseURL: "https://react-typesctipt-firebase-default-rtdb.firebaseio.com", 5 | projectId: "react-typesctipt-firebase", 6 | storageBucket: "react-typesctipt-firebase.appspot.com", 7 | messagingSenderId: "493729555025", 8 | appId: "1:493729555025:web:b10f0bd89ae5936626ce70", 9 | measurementId: "G-TTY9GP6YQR" 10 | 11 | // firebaseConfig: import.meta.env.VITE_FIREBASE_CONFIG as string, 12 | //giphyAPIKey: import.meta.env.VITE_GIPHY_API_KEY as string, 13 | }; 14 | 15 | export default configs; -------------------------------------------------------------------------------- /src/shared/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_AVATAR = "/default-avatar.png"; 2 | export const IMAGE_PROXY = (url: string) => `${url}`; 3 | export const STICKERS_URL = 4 | "https://cdn.jsdelivr.net/gh/naptestdev/zalo-stickers/data/favourite.json"; 5 | export const FILE_ICON = (extension: string) => 6 | `https://cdn.jsdelivr.net/gh/napthedev/file-icons/file/${extension}.svg`; 7 | export const REACTIONS_UI: { 8 | [key: string]: { 9 | icon: string; 10 | gif: string; 11 | }; 12 | } = { 13 | Like: { 14 | icon: "/reactions-icon/like.svg", 15 | gif: "/reactions/like.gif", 16 | }, 17 | Love: { 18 | icon: "/reactions-icon/love.svg", 19 | gif: "/reactions/love.gif", 20 | }, 21 | Care: { 22 | icon: "/reactions-icon/care.svg", 23 | gif: "/reactions/care.gif", 24 | }, 25 | Haha: { 26 | icon: "/reactions-icon/haha.svg", 27 | gif: "/reactions/haha.gif", 28 | }, 29 | Wow: { 30 | icon: "/reactions-icon/wow.svg", 31 | gif: "/reactions/wow.gif", 32 | }, 33 | Sad: { 34 | icon: "/reactions-icon/sad.svg", 35 | gif: "/reactions/sad.gif", 36 | }, 37 | Angry: { 38 | icon: "/reactions-icon/angry.svg", 39 | gif: "/reactions/angry.gif", 40 | }, 41 | }; 42 | 43 | export const EMOJI_REPLACEMENT = { 44 | "😭": ["ToT", "T-T", "T_T", "T.T", ":((", ":-(("], 45 | "😓": ["'-_-"], 46 | "😜": [";p", ";-p", ";P", ";-P"], 47 | "😑": ["-_-"], 48 | "😢": [":'(", ":'-("], 49 | "😞": [":(", ":-(", "=(", ")=", ":["], 50 | "😐": [":|", ":-|"], 51 | "😛": [":P", ":-P", ":p", ":-p", "=P", "=p"], 52 | "😁": [":D", ":-D", "=D", ":d", ":-d", "=d"], 53 | "😗": [":*", ":-*"], 54 | "😇": ["O:)", "O:-)"], 55 | "😳": ["O_O", "o_o", "0_0"], 56 | "😊": ["^_^", "^~^", "=)"], 57 | "😠": [">:(", ">:-(", ">:o", ">:-o", ">:O", ">:-O"], 58 | "😎": ["8)", "B)", "8-)", "B-)", ":))"], 59 | "😚": ["-3-"], 60 | "😉": [";)", ";-)"], 61 | "😲": [":O", ":o", ":-O", ":-o"], 62 | "😣": [">_<", ">.<"], 63 | "😘": [";*", ";-*"], 64 | "😕": [":/", ":-/", ":\\", ":-\\", "=/", "=\\"], 65 | "🙂": [":)", ":]", ":-)", "(:", "(="], 66 | "♥": ["<3"], 67 | "😂": [":')"], 68 | "🤑": ["$-)"], 69 | }; 70 | 71 | export const EMOJI_REGEX = /^\p{Extended_Pictographic}$/u; 72 | 73 | export const THEMES = [ 74 | "#0D90F3", 75 | "#EB3A2A", 76 | "#0AD4EB", 77 | "#643ECB", 78 | "#93BF34", 79 | "#E84FCF", 80 | "#B43F3F", 81 | "#E6A50A", 82 | "#69C90C", 83 | ]; 84 | -------------------------------------------------------------------------------- /src/shared/firebase.ts: -------------------------------------------------------------------------------- 1 | import { enableIndexedDbPersistence, getFirestore } from "firebase/firestore"; 2 | 3 | import configs from "./configs"; 4 | import { getAuth } from "firebase/auth"; 5 | import { getStorage } from "firebase/storage"; 6 | import { initializeApp } from "firebase/app"; 7 | 8 | 9 | const firebaseConfig = configs; 10 | const firebaseApp = initializeApp(firebaseConfig); 11 | 12 | export const auth = getAuth(firebaseApp); 13 | export const db = getFirestore(firebaseApp); 14 | export const storage = getStorage(firebaseApp); 15 | 16 | enableIndexedDbPersistence(db, { forceOwnership: false }); -------------------------------------------------------------------------------- /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | export interface ConversationInfo { 2 | users: string[]; 3 | group?: { 4 | admins: string[]; 5 | groupName: null | string; 6 | groupImage: null | string; 7 | }; 8 | 9 | seen: { 10 | [key: string]: string; 11 | }; 12 | updatedAt: { 13 | seconds: number; 14 | nanoseconds: number; 15 | }; 16 | theme: string; 17 | } 18 | 19 | export interface SavedUser { 20 | uid: string; 21 | email: string | null; 22 | displayName: string; 23 | photoURL: string; 24 | phoneNumber: string | null; 25 | } 26 | 27 | export interface MessageItem { 28 | id?: string; 29 | sender: string; 30 | content: string; 31 | replyTo?: string; 32 | file?: { 33 | name: string; 34 | size: number; 35 | }; 36 | createdAt: { 37 | seconds: number; 38 | nanoseconds: number; 39 | }; 40 | type: "text" | "image" | "file" | "sticker" | "removed"; 41 | reactions: { 42 | [key: string]: number; 43 | }; 44 | } 45 | 46 | export interface StickerCollection { 47 | name: string; 48 | thumbnail: string; 49 | icon: string; 50 | id: string; 51 | stickers: { 52 | id: string; 53 | spriteURL: string; 54 | }[]; 55 | } 56 | 57 | export type StickerCollections = StickerCollection[]; -------------------------------------------------------------------------------- /src/shared/utils.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | // @ts-ignore 3 | import kebabCase from "lodash.kebabcase"; 4 | 5 | export const formatFileName = (name: string) => { 6 | const splitted = name.split("."); 7 | 8 | const extension = splitted.slice(-1)[0]; 9 | const baseName = splitted.slice(0, -1).join("."); 10 | 11 | return `${Date.now()}-${ 12 | kebabCase( 13 | baseName 14 | .normalize("NFD") 15 | .replace(/[\u0300-\u036f]/g, "") 16 | .replace(/đ/g, "d") 17 | .replace(/Đ/g, "D") 18 | )}.${extension}`; 19 | }; 20 | 21 | export const formatFileSize = (size: number) => { 22 | let i = Math.floor(Math.log(size) / Math.log(1024)); 23 | 24 | return `${(size / Math.pow(1024, i)).toFixed(1)} ${ 25 | ["B", "KB", "MB", "GB", "TB"][i] 26 | }`; 27 | }; 28 | 29 | export const formatDate = (timestamp: number) => { 30 | const date = new Date(timestamp); 31 | const formatter = dayjs(date); 32 | const now = new Date(); 33 | 34 | if (dayjs().isSame(formatter, "date")) return formatter.format("h:mm A"); 35 | if (dayjs().isSame(formatter, "week")) return formatter.format("ddd h:mm A"); 36 | if (now.getFullYear() === date.getFullYear())return formatter.format("MMM DD h:mm A"); 37 | 38 | return formatter.format("DD MMM YYYY h:mm A"); 39 | }; 40 | 41 | export const splitLinkFromMessage = (message: string) => { 42 | const URL_REGEX = 43 | /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/gm; 44 | 45 | const result = message.split(" ").reduce((acc, item) => { 46 | const isURL = URL_REGEX.test(item); 47 | if (isURL) acc.push({ link: item }); 48 | else { 49 | if (typeof acc.slice(-1)[0] === "string") { 50 | acc = [...acc.slice(0, -1), `${acc.slice(-1)[0]} ${item}`]; 51 | } else { 52 | acc.push(item); 53 | } 54 | } 55 | 56 | return acc; 57 | }, [] as ({ link: string } | string)[]); 58 | 59 | return result; 60 | }; -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { User } from "firebase/auth"; 2 | import create from "zustand"; 3 | 4 | interface StoreType { 5 | currentUser: undefined | null | User; 6 | setCurrentUser: (user: User | null) => void; 7 | } 8 | 9 | export const useStore = create((set: any) => ({ 10 | currentUser: undefined, 11 | setCurrentUser: (user) => set({ currentUser: user }), 12 | })); 13 | -------------------------------------------------------------------------------- /src/styles/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | @apply bg-dark text-gray-100; 7 | --primary-color: #0d90f3; 8 | } 9 | 10 | input[type="checkbox"] { 11 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e"); 12 | @apply appearance-none outline-none w-4 h-4 rounded bg-white transition-all duration-300 checked:bg-primary; 13 | } 14 | 15 | .check-overlay { 16 | @apply relative; 17 | } 18 | 19 | .check-overlay::after { 20 | @apply bg-center bg-no-repeat z-10 w-full h-full absolute top-0 left-0; 21 | content: ""; 22 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e"); 23 | } 24 | 25 | ::-webkit-scrollbar { 26 | @apply w-[10px] h-[10px]; 27 | } 28 | ::-webkit-scrollbar-track { 29 | @apply bg-transparent; 30 | } 31 | ::-webkit-scrollbar-thumb { 32 | @apply bg-[#666] rounded-2xl; 33 | border: 1px solid #191a1f; 34 | } 35 | ::-webkit-scrollbar-thumb:hover { 36 | @apply bg-[#777]; 37 | } 38 | ::-webkit-scrollbar-button { 39 | @apply hidden; 40 | } 41 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"], 3 | theme: { 4 | extend: { 5 | colors: { 6 | dark: "#242526", 7 | "dark-lighten": "#3A3B3C", 8 | primary: "var(--primary-color)", 9 | }, 10 | }, 11 | keyframes: { 12 | "fade-in": { 13 | from: { opacity: 0 }, 14 | to: { opacity: 1 }, 15 | }, 16 | }, 17 | animation: { 18 | "fade-in": "fade-in 0.3s forwards", 19 | }, 20 | }, 21 | plugins: [], 22 | }; 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": false, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["./src"] 20 | } 21 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }); 8 | --------------------------------------------------------------------------------