diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7ec8df2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: backend/package-lock.json + - run: npm ci + - run: npm run build + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm run build + env: + VITE_API_URL: http://localhost:3000/api + VITE_SOCKET_URL: http://localhost:3000 + VITE_SIP_WS_URL: wss://sip.example.com + VITE_APP_PORT: 5173 + VITE_APP_NAME: ChatSphere diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..30ff6e0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Keyur Machhi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 5015e29..ebe9daf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # Chat Sphere — MERN + WebRTC Chat App +[![CI](https://github.com/sudo-devKm/chat-sphere/actions/workflows/ci.yml/badge.svg)](https://github.com/sudo-devKm/chat-sphere/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Live Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://chat-sphere-tawny.vercel.app/) + +**[Live Demo →](https://chat-sphere-tawny.vercel.app/)** + A modern chat & WebRTC calling application built with TypeScript: - Backend: Node.js, Express, Socket.IO, MongoDB, Redis, JWT authentication, S3 integration @@ -315,7 +321,7 @@ Contributing License -- This repository does not include an explicit license file. Add a LICENSE (for example MIT) if you want to make the code open source. +- Licensed under the [MIT License](LICENSE). --- diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..8b04c69 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,5 @@ +VITE_API_URL=http://localhost:3000/api +VITE_SOCKET_URL=http://localhost:3000 +VITE_SIP_WS_URL=wss://sip.example.com +VITE_APP_PORT=5173 +VITE_APP_NAME=ChatSphere diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index ab8e167..b952b48 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -21,7 +21,7 @@ export default defineConfig([ }, rules: { '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-var': 'warn', + '@typescript-eslint/no-unused-vars': 'warn', }, }, ]); diff --git a/frontend/src/components/call/CallTimer.tsx b/frontend/src/components/call/CallTimer.tsx index 957e1ed..78098f4 100644 --- a/frontend/src/components/call/CallTimer.tsx +++ b/frontend/src/components/call/CallTimer.tsx @@ -18,7 +18,6 @@ export const CallTimer: React.FC = ({ useEffect(() => { if (!startTime) { - setDuration(0); return; } @@ -43,7 +42,7 @@ export const CallTimer: React.FC = ({
{showIcon && } - {formatTime(duration)} + {formatTime(startTime ? duration : 0)}
); diff --git a/frontend/src/components/chat/message-input/InputArea.tsx b/frontend/src/components/chat/message-input/InputArea.tsx index 3ca7bd4..431dac5 100644 --- a/frontend/src/components/chat/message-input/InputArea.tsx +++ b/frontend/src/components/chat/message-input/InputArea.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { TextField, IconButton, Box } from '@mui/material'; import AttachFileIcon from '@mui/icons-material/AttachFile'; import EmojiEmotionsOutlinedIcon from '@mui/icons-material/EmojiEmotionsOutlined'; @@ -32,6 +33,11 @@ export const InputArea = ({ fileInputRef, }: InputAreaProps) => { const isSendDisabled = uploading || (!message.trim() && !selectedFile); + // Track the emoji button DOM node in state (set via the ref callback + // below) instead of reading emojiBtnRef.current during render. + const [emojiAnchor, setEmojiAnchor] = useState( + null, + ); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -64,7 +70,10 @@ export const InputArea = ({ {/* Emoji Button */} { + emojiBtnRef.current = node; + setEmojiAnchor(node); + }} onClick={() => setShowEmoji(!showEmoji)} className='bg-white text-gray-600 hover:bg-gray-100 border border-gray-200 shadow-sm' sx={{ @@ -81,7 +90,7 @@ export const InputArea = ({ setShowEmoji(false)} onEmojiClick={onEmojiClick} /> diff --git a/frontend/src/components/chat/message-input/MessageInput.tsx b/frontend/src/components/chat/message-input/MessageInput.tsx index b28c4cc..02d2749 100644 --- a/frontend/src/components/chat/message-input/MessageInput.tsx +++ b/frontend/src/components/chat/message-input/MessageInput.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { SocketEvent } from '@/constants/socket.events'; -import { useSocket } from '@/providers/SocketProvider'; +import { useSocket } from '@/providers/SocketContext'; import { toastError } from '@/components/toaster/Toast'; import { useFileUpload } from '@/hooks/useFileUpload'; import { InputContainer } from './InputContainer'; diff --git a/frontend/src/components/chat/message-list/VirtualMessageRow.tsx b/frontend/src/components/chat/message-list/VirtualMessageRow.tsx index 8b345fe..b721efd 100644 --- a/frontend/src/components/chat/message-list/VirtualMessageRow.tsx +++ b/frontend/src/components/chat/message-list/VirtualMessageRow.tsx @@ -21,6 +21,9 @@ export const VirtualMessageRow = ({ return (
= ({ const [imageError, setImageError] = useState(false); const [_, setClientDimensions] = useState(null); + // Reset states when the preview URL changes (adjusting state during + // render instead of an effect avoids an extra render pass) + const [prevPreviewUrl, setPrevPreviewUrl] = useState(previewUrl); + if (previewUrl !== prevPreviewUrl) { + setPrevPreviewUrl(previewUrl); + setImageLoaded(false); + setImageError(false); + setClientDimensions(null); + } + // Calculate display dimensions based on maxWidth constraint const displayDimensions = useMemo(() => { if (!dimensions) { @@ -118,13 +128,6 @@ export const ChatImage: React.FC = ({ setImageLoaded(false); }; - // Reset states when URL changes - useEffect(() => { - setImageLoaded(false); - setImageError(false); - setClientDimensions(null); - }, [previewUrl]); - // Try to extract dimensions from preview URL if backend dimensions are missing useEffect(() => { if (previewUrl && !dimensions && !loading && !imageError) { diff --git a/frontend/src/components/toaster/Toast.tsx b/frontend/src/components/toaster/Toast.tsx index 9e98b0e..d72424e 100644 --- a/frontend/src/components/toaster/Toast.tsx +++ b/frontend/src/components/toaster/Toast.tsx @@ -1,12 +1,11 @@ import toast, { type ToastOptions } from 'react-hot-toast'; import { - CheckCircle, - XCircle, - Info, - AlertCircle, - X, - Loader2, -} from 'lucide-react'; + SuccessToast, + ErrorToast, + InfoToast, + WarningToast, + LoadingToast, +} from './ToastVariants'; // Toast configuration const toastConfig: ToastOptions = { @@ -14,195 +13,6 @@ const toastConfig: ToastOptions = { duration: 4000, }; -// Progress Bar Component -const ProgressBar = ({ duration }: { duration: number }) => ( -
-
-
-); - -// Close Button Component -const CloseButton = ({ t }: { t: any }) => ( - -); - -// Custom toast style function -const getToastStyle = ( - type: 'success' | 'error' | 'info' | 'warning' | 'loading', -): string => { - const baseClasses = ` - flex items-center gap-3 p-4 pr-10 rounded-xl shadow-lg - backdrop-blur-sm border transform transition-all duration-300 - max-w-md min-w-[300px] relative overflow-hidden - `; - - const typeClasses = { - success: - 'bg-gradient-to-r from-emerald-50 to-white border-emerald-100 text-emerald-800', - error: - 'bg-gradient-to-r from-rose-50 to-white border-rose-100 text-rose-800', - info: 'bg-gradient-to-r from-blue-50 to-white border-blue-100 text-blue-800', - warning: - 'bg-gradient-to-r from-amber-50 to-white border-amber-100 text-amber-800', - loading: - 'bg-gradient-to-r from-slate-50 to-white border-slate-100 text-slate-800', - }; - - return `${baseClasses} ${typeClasses[type]}`; -}; - -// Success Toast Component -const SuccessToast = ({ - message, - t, - duration, -}: { - message: string; - t: any; - duration: number; -}) => ( -
-
- -
- -
-
-
-

Success

-

{message}

-
- - -
-); - -// Error Toast Component -const ErrorToast = ({ - message, - t, - duration, -}: { - message: string; - t: any; - duration: number; -}) => ( -
-
- -
- -
-
-
-

Error

-

{message}

-
- - -
-); - -// Info Toast Component -const InfoToast = ({ - message, - t, - duration, -}: { - message: string; - t: any; - duration: number; -}) => ( -
-
- -
- -
-
-
-

Info

-

{message}

-
- - -
-); - -// Warning Toast Component -const WarningToast = ({ - message, - t, - duration, -}: { - message: string; - t: any; - duration: number; -}) => ( -
-
- -
- -
-
-
-

Warning

-

{message}

-
- - -
-); - -// Loading Toast Component -const LoadingToast = ({ message, t }: { message: string; t: any }) => ( -
- -
-

Loading

-

{message}

-
- -
-); - // Export toast functions export const toastSuccess = (message: string, options?: ToastOptions): void => { toast.custom( diff --git a/frontend/src/components/toaster/ToastVariants.tsx b/frontend/src/components/toaster/ToastVariants.tsx new file mode 100644 index 0000000..7487dfe --- /dev/null +++ b/frontend/src/components/toaster/ToastVariants.tsx @@ -0,0 +1,191 @@ +import toast from 'react-hot-toast'; +import { CheckCircle, XCircle, Info, AlertCircle, X, Loader2 } from 'lucide-react'; + +// Progress Bar Component +const ProgressBar = ({ duration }: { duration: number }) => ( +
+
+
+); + +// Close Button Component +const CloseButton = ({ t }: { t: any }) => ( + +); + +// Custom toast style function +const getToastStyle = ( + type: 'success' | 'error' | 'info' | 'warning' | 'loading', +): string => { + const baseClasses = ` + flex items-center gap-3 p-4 pr-10 rounded-xl shadow-lg + backdrop-blur-sm border transform transition-all duration-300 + max-w-md min-w-[300px] relative overflow-hidden + `; + + const typeClasses = { + success: + 'bg-gradient-to-r from-emerald-50 to-white border-emerald-100 text-emerald-800', + error: + 'bg-gradient-to-r from-rose-50 to-white border-rose-100 text-rose-800', + info: 'bg-gradient-to-r from-blue-50 to-white border-blue-100 text-blue-800', + warning: + 'bg-gradient-to-r from-amber-50 to-white border-amber-100 text-amber-800', + loading: + 'bg-gradient-to-r from-slate-50 to-white border-slate-100 text-slate-800', + }; + + return `${baseClasses} ${typeClasses[type]}`; +}; + +// Success Toast Component +export const SuccessToast = ({ + message, + t, + duration, +}: { + message: string; + t: any; + duration: number; +}) => ( +
+
+ +
+ +
+
+
+

Success

+

{message}

+
+ + +
+); + +// Error Toast Component +export const ErrorToast = ({ + message, + t, + duration, +}: { + message: string; + t: any; + duration: number; +}) => ( +
+
+ +
+ +
+
+
+

Error

+

{message}

+
+ + +
+); + +// Info Toast Component +export const InfoToast = ({ + message, + t, + duration, +}: { + message: string; + t: any; + duration: number; +}) => ( +
+
+ +
+ +
+
+
+

Info

+

{message}

+
+ + +
+); + +// Warning Toast Component +export const WarningToast = ({ + message, + t, + duration, +}: { + message: string; + t: any; + duration: number; +}) => ( +
+
+ +
+ +
+
+
+

Warning

+

{message}

+
+ + +
+); + +// Loading Toast Component +export const LoadingToast = ({ message, t }: { message: string; t: any }) => ( +
+ +
+

Loading

+

{message}

+
+ +
+); diff --git a/frontend/src/components/users-list/VirtualRowWrapper.tsx b/frontend/src/components/users-list/VirtualRowWrapper.tsx index 233484f..57461ab 100644 --- a/frontend/src/components/users-list/VirtualRowWrapper.tsx +++ b/frontend/src/components/users-list/VirtualRowWrapper.tsx @@ -15,6 +15,9 @@ export const VirtualRowWrapper = ({ return (
{ // Get call direction based on incoming state const callDirection: CallDirection = incoming ? 'incoming' : 'outgoing'; - const getPeerConnection = () => { + const getPeerConnection = useCallback(() => { if (pcRef.current) return pcRef.current; const pc = new RTCPeerConnection({ @@ -76,7 +76,7 @@ export const useCall = (_selfUserId: string) => { pcRef.current = pc; return pc; - }; + }, [callId, socket]); const toggleSpeaker = useCallback(() => { setIsSpeakerOff((prev) => !prev); @@ -155,24 +155,6 @@ export const useCall = (_selfUserId: string) => { [socket], ); - const acceptCall = useCallback(async () => { - if (!callId) return; - - try { - // Stop ringtone when call is accepted - audioService.stopRingtone(); - const stream = await initializeLocalStream(); - const pc = getPeerConnection(); - attachLocalTracks(pc, stream); - - socket.emit(SocketEvent.CALL_ANSWER, { callId }); - setCallState('connected'); - } catch (error) { - console.error('Failed to accept call:', error); - endCall(); - } - }, [callId, socket]); - const attachLocalTracks = (pc: RTCPeerConnection, stream: MediaStream) => { const senders = pc.getSenders(); @@ -238,6 +220,24 @@ export const useCall = (_selfUserId: string) => { return stream; }, [callType]); + const acceptCall = useCallback(async () => { + if (!callId) return; + + try { + // Stop ringtone when call is accepted + audioService.stopRingtone(); + const stream = await initializeLocalStream(); + const pc = getPeerConnection(); + attachLocalTracks(pc, stream); + + socket.emit(SocketEvent.CALL_ANSWER, { callId }); + setCallState('connected'); + } catch (error) { + console.error('Failed to accept call:', error); + endCall(); + } + }, [callId, socket, initializeLocalStream, getPeerConnection, endCall]); + useEffect(() => { const handleCallIncoming = ({ data }: any) => { setCallId(data.callId); diff --git a/frontend/src/hooks/useChatMessages.ts b/frontend/src/hooks/useChatMessages.ts index da73c0e..7477309 100644 --- a/frontend/src/hooks/useChatMessages.ts +++ b/frontend/src/hooks/useChatMessages.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { SocketEvent } from '@/constants/socket.events'; -import { useSocket } from '@/providers/SocketProvider'; +import { useSocket } from '@/providers/SocketContext'; import { getMessages } from '@/api/chat/chat.api'; const PAGE_SIZE = 20; diff --git a/frontend/src/hooks/useChatSession.ts b/frontend/src/hooks/useChatSession.ts index 23752ee..1411173 100644 --- a/frontend/src/hooks/useChatSession.ts +++ b/frontend/src/hooks/useChatSession.ts @@ -5,11 +5,17 @@ export function useChatSession(userId: string) { const [chatId, setChatId] = useState(null); const [loading, setLoading] = useState(false); + // Flip into the loading state as soon as userId changes, during render + // rather than inside the effect below. + const [prevUserId, setPrevUserId] = useState(userId); + if (userId !== prevUserId) { + setPrevUserId(userId); + if (userId) setLoading(true); + } + useEffect(() => { if (!userId) return; - setLoading(true); - getChatWithUser(userId) .then((res) => { setChatId(res.data.data.chatId); diff --git a/frontend/src/hooks/useDashboardPresence.ts b/frontend/src/hooks/useDashboardPresence.ts index 3c4b50c..7b21668 100644 --- a/frontend/src/hooks/useDashboardPresence.ts +++ b/frontend/src/hooks/useDashboardPresence.ts @@ -1,4 +1,4 @@ -import { useSocket } from '@/providers/SocketProvider'; +import { useSocket } from '@/providers/SocketContext'; import type { SocketSuccessResponse } from '@/types/socket.types'; import { useEffect, useRef } from 'react'; @@ -13,7 +13,7 @@ export const useDashboardPresence = (onOnlineIds: (ids: string[]) => void) => { socket.on( 'users:online', ({ data }: SocketSuccessResponse<{ users: string[] }>) => { - onOnlineIds(data?.users!); + onOnlineIds(data!.users); }, ); socket.emit('users:sync'); diff --git a/frontend/src/hooks/useMessageInput.ts b/frontend/src/hooks/useMessageInput.ts index bc8a44f..fac342d 100644 --- a/frontend/src/hooks/useMessageInput.ts +++ b/frontend/src/hooks/useMessageInput.ts @@ -1,6 +1,6 @@ import { useCallback, useState, useEffect } from 'react'; import { SocketEvent } from '@/constants/socket.events'; -import { useSocket } from '@/providers/SocketProvider'; +import { useSocket } from '@/providers/SocketContext'; import { toastError } from '@/components/toaster/Toast'; import { useFileUpload } from '@/hooks/useFileUpload'; diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx index 288e5bf..a2239e5 100644 --- a/frontend/src/pages/dashboard/DashboardPage.tsx +++ b/frontend/src/pages/dashboard/DashboardPage.tsx @@ -32,7 +32,7 @@ import { export const DashboardPage = () => { const [selectedUserId, setSelectedUserId] = useState(null); const [onlineIds, setOnlineIds] = useState>(new Set()); - const selfUserId = useAuthStore((s) => s.user?._id!); + const selfUserId = useAuthStore((s) => s.user!._id); const { users, loadMore, order, isLoading, setPage } = useDashboardUsers(); const { chatId, loading } = useChatSession(selectedUserId ?? ''); const [callStartTime, setCallStartTime] = useState(); @@ -62,14 +62,17 @@ export const DashboardPage = () => { loadMore(); }, [loadMore]); - // Track when call becomes active - useEffect(() => { + // Track when call becomes active. Derived from callState during render + // (rather than an effect) since callStartTime only depends on callState. + const [prevCallState, setPrevCallState] = useState(callState); + if (callState !== prevCallState) { + setPrevCallState(callState); if (callState === 'connected' && !callStartTime) { setCallStartTime(new Date()); } else if (callState !== 'connected') { setCallStartTime(undefined); } - }, [callState, callStartTime]); + } const handleStartCall = useCallback( (type: 'audio' | 'video') => { diff --git a/frontend/src/providers/SocketContext.ts b/frontend/src/providers/SocketContext.ts new file mode 100644 index 0000000..feafbe0 --- /dev/null +++ b/frontend/src/providers/SocketContext.ts @@ -0,0 +1,14 @@ +import { getSocket } from '@/socket/socket'; +import { createContext, useContext } from 'react'; + +export type SocketType = ReturnType; + +export const SocketContext = createContext(null); + +export const useSocket = (): SocketType => { + const socket = useContext(SocketContext); + if (!socket) { + throw new Error('useSocket must be used inside SocketProvider'); + } + return socket; +}; diff --git a/frontend/src/providers/SocketProvider.tsx b/frontend/src/providers/SocketProvider.tsx index 7cedcd7..be1782f 100644 --- a/frontend/src/providers/SocketProvider.tsx +++ b/frontend/src/providers/SocketProvider.tsx @@ -1,35 +1,13 @@ +import { useEffect, useState, type ReactNode } from 'react'; import { getSocket } from '@/socket/socket'; -import { - createContext, - useContext, - useEffect, - useRef, - type ReactNode, -} from 'react'; - -type SocketType = ReturnType; - -const SocketContext = createContext(null); - -export const useSocket = (): SocketType => { - const socket = useContext(SocketContext); - if (!socket) { - throw new Error('useSocket must be used inside SocketProvider'); - } - return socket; -}; +import { SocketContext, type SocketType } from './SocketContext'; export const SocketProvider: React.FC<{ children: ReactNode }> = ({ children, }) => { - const socketRef = useRef(null); - - if (!socketRef.current) { - socketRef.current = getSocket(); - } + const [socket] = useState(() => getSocket()); useEffect(() => { - const socket = socketRef.current!; // ---- CONNECT ---- socket.connect(); @@ -56,11 +34,9 @@ export const SocketProvider: React.FC<{ children: ReactNode }> = ({ socket?.off?.('disconnect', onDisconnect); socket?.off?.('connect_error', onConnectError); }; - }, []); + }, [socket]); return ( - - {children} - + {children} ); };