diff --git a/app/src/components/Avatar.tsx b/app/src/components/Avatar.tsx index 96e167ea..06c22e47 100644 --- a/app/src/components/Avatar.tsx +++ b/app/src/components/Avatar.tsx @@ -23,6 +23,7 @@ const StyledAvatar = styled.div<{ $image?: string; $size?: string }>` font-weight: bold; font-size: 1rem; text-transform: uppercase; + z-index: 1; `; type PropsType = { diff --git a/app/src/components/Main.tsx b/app/src/components/Main.tsx index eef739de..b03092e7 100644 --- a/app/src/components/Main.tsx +++ b/app/src/components/Main.tsx @@ -10,7 +10,7 @@ const StyledMain = styled.main` position: relative; overflow: hidden; background: var(--color-chat-wallpaper-1); - + padding-top: 1rem; &::before { content: ""; position: absolute; diff --git a/app/src/components/side-bar/SideBar.tsx b/app/src/components/side-bar/SideBar.tsx index c605fa35..7f4fcedc 100644 --- a/app/src/components/side-bar/SideBar.tsx +++ b/app/src/components/side-bar/SideBar.tsx @@ -40,7 +40,7 @@ const StyledSidebar = styled.aside<{ $isExiting: boolean }>` background-color: var(--color-background); overflow: hidden; position: relative; - + padding-top: 1rem; display: flex; flex-direction: column; diff --git a/app/src/data/icons.tsx b/app/src/data/icons.tsx index 94062158..82bb96b4 100644 --- a/app/src/data/icons.tsx +++ b/app/src/data/icons.tsx @@ -57,6 +57,8 @@ enum icons { Info, Phone, ArrowForward, + Mute, + EndCall, } type iconStrings = keyof typeof icons; @@ -390,6 +392,14 @@ const iconImports: Record = { importFn: () => import("@mui/icons-material/ArrowForward"), defaultProps: { fontSize: "large" }, }, + Mute: { + importFn: () => import("@mui/icons-material/Mic"), + defaultProps: { fontSize: "large" }, + }, + EndCall: { + importFn: () => import("@mui/icons-material/CallEnd"), + defaultProps: { fontSize: "large" }, + }, }; const iconCache = new Map(); diff --git a/app/src/features/calls/CallLayout.tsx b/app/src/features/calls/CallLayout.tsx new file mode 100644 index 00000000..abf2a47c --- /dev/null +++ b/app/src/features/calls/CallLayout.tsx @@ -0,0 +1,254 @@ +import styled from "styled-components"; +import { peerConnection } from "./call"; +import { STATIC_MEDIA_URL } from "@constants"; + +import { getAvatarName } from "utils/helpers"; +import { createPortal } from "react-dom"; +import Icon from "@components/Icon"; +import { getIcon } from "@data/icons"; +import { useState } from "react"; + +const ModalContainer = styled.div` + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: center; + align-items: center; + opacity: 1; + transition: opacity 0.15s ease; + z-index: 6; +`; + +const ModalBackdrop = styled.div` + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: -1; + background-color: rgba(0, 0, 0, 0.25); +`; + +const ModalDialog = styled.div` + border-radius: 5px; + position: relative; + display: inline-flex; + flex-direction: column; + width: 100%; + max-width: 35rem; + min-width: 17.5rem; + margin: 2rem auto; + background-color: var(--color-background); + box-shadow: 0 0.25rem 0.5rem 0.125rem var(--color-default-shadow); + transform: translate3d(0, -1rem, 0); + transition: + transform 0.2s ease, + opacity 0.2s ease; +`; +const ButtonsContainer = styled.div` + display: flex; + position: absolute; + bottom: 1rem; + -webkit-user-select: none; + user-select: none; +`; +const AvatarContainer = styled.div` + border-radius: inherit; + overflow: hidden; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-image: linear-gradient( + var(--color-white) -125%, + var(--color-user) + ); +`; +const ModalContent = styled.div` + border-radius: inherit; + display: flex; + flex-direction: column; + align-items: center; + height: 80vh; + padding: 0; +`; +const StyledAvatar = styled.div<{ $image?: string }>` + border-radius: inherit; + width: 100%; + height: 100%; + + background: ${({ $image }) => + $image + ? `url(${STATIC_MEDIA_URL + $image}) center/cover no-repeat` + : "var(--color-avatar)"}; + + color: white; + + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 5rem; + text-transform: uppercase; +`; +const RoundButton = styled.button<{ + $bgColor?: string; + $bgColorHover?: string; +}>` + outline: none !important; + display: flex; + align-items: center; + justify-content: center; + height: 3.5rem; + border-radius: 50%; + border: 0; + background-color: ${({ $bgColor }) => $bgColor || "rgba(0, 0, 0, 0)"}; + background-size: cover; + padding: 0.625rem; + color: #fff; + flex-shrink: 0; + position: relative; + overflow: hidden; + transition: + background-color 0.15s, + color 0.15s; + text-decoration: none !important; + text-transform: uppercase; + text-align: right; + font-size: 3.5rem; + font-weight: 200; + &:hover { + background-color: ${({ $bgColorHover }) => + $bgColorHover || "rgba(0, 0, 0, 0.1)"}; + } +`; + +const ButtonContainer = styled.div` + width: 5rem; + display: flex; + flex-direction: column; + align-items: center; +`; +const ButtonText = styled.div` + color: #fff; + font-size: 0.75rem; + text-transform: lowercase; + margin-top: 0.25rem; + white-space: nowrap; +`; +const NameContainer = styled.div` + position: absolute; + top: 1rem; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + margin-top: 0; + padding-top: 4rem; + padding-bottom: 2rem; + margin-bottom: auto; + color: #fff; + pointer-events: none; + -webkit-user-select: none; + user-select: none; +`; +const TopBar = styled.div` + top: 0.5rem; + width: 100%; + display: flex; + align-items: center; + justify-content: flex-end; + color: #fff; + position: absolute; + padding: 0.5rem; + text-align: right; +`; +const ActiveHeaderOpen = styled.div` + position: fixed; + margin-bottom: 0.5rem; + top: 0; + left: 0; + height: 1rem; + width: 100%; + z-index: 6; + display: flex; + justify-content: center; + font-weight: 500; + font-size: 0.875rem; + color: #fff; + align-items: center; + padding: 0 1rem; + background: linear-gradient(135deg, rgb(49, 82, 232), rgb(143, 74, 172)); + transform: translateY(0); +`; +type PropsType = { + image?: string | undefined; + name: string | undefined; + isCollapsed: boolean; + setIsCollapsed: (arg0: boolean) => void; +}; + +export default function CallLayout({ + name, + image, + isCollapsed, + setIsCollapsed, +}: PropsType) { + return ( + <> + {!isCollapsed + ? createPortal( + + + + + + + {!image && getAvatarName(name)} + + + setIsCollapsed(true)}> + × + + + +

{name}

+ state +
+ + + {getIcon("Mute")} + unmute + + + + {getIcon("EndCall")} + + end call + + +
+
+
+
, + document.body, + ) + : createPortal( + setIsCollapsed(false)}> + {name} + , + document.body, + )} + + ); +} diff --git a/app/src/features/calls/call.ts b/app/src/features/calls/call.ts new file mode 100644 index 00000000..bc5ba96e --- /dev/null +++ b/app/src/features/calls/call.ts @@ -0,0 +1,62 @@ +export let callState = "idle"; +const stunServers = { + iceServers: [ + { urls: ["stun:stun.l.google.com:19302", "stun:stun.l.google.com:5349"] }, + ], +}; +async function getVoiceInput(): Promise { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + return stream; + } catch (error) { + console.error("Error accessing microphone:", error); + return null; + } +} +export let peerConnection: RTCPeerConnection; +let localStream: MediaStream | null; +let RemoteStream: MediaStream; +export const connectToPeer = async () => { + callState = "connecting"; + peerConnection = new RTCPeerConnection(stunServers); + if (!localStream) localStream = await getVoiceInput(); + console.log(localStream); + localStream?.getTracks().forEach((t) => { + peerConnection.addTrack(t, localStream); + }); + + peerConnection.ontrack = (e) => { + e.streams[0]?.getTracks().forEach((track) => RemoteStream.addTrack(track)); + }; + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(null); + }, 10000); + + peerConnection.onicecandidate = (event) => { + if (!event.candidate) { + clearTimeout(timeout); + resolve(null); + } + }; + }); + const offer = await peerConnection.createOffer(); + await peerConnection.setLocalDescription(offer); + return JSON.stringify(offer); +}; + +export const createAnswer = async (offer: string) => { + await connectToPeer(); + const offer_parsed = JSON.parse(offer); + await peerConnection.setRemoteDescription(offer_parsed); + const answer = await peerConnection.createAnswer(); + await peerConnection.setLocalDescription(answer); + return JSON.stringify(answer); +}; +export const startCall = async (answer: string) => { + callState = "ongoing"; + const offer_parsed = JSON.parse(answer); + await peerConnection.setRemoteDescription(offer_parsed); + //start call +}; diff --git a/app/src/features/chats/Topbar.tsx b/app/src/features/chats/Topbar.tsx index 9f95d0f4..4a4e17b7 100644 --- a/app/src/features/chats/Topbar.tsx +++ b/app/src/features/chats/Topbar.tsx @@ -1,28 +1,24 @@ import { useState } from "react"; import styled from "styled-components"; - import Avatar from "@components/Avatar"; import { getIcon } from "@data/icons"; import Icon from "@components/Icon"; import SearchBar from "@features/search/components/SearchBar"; import PinnedMessages from "@features/pin-messages/components/PinnedMessages"; -import { useAppDispatch, useAppSelector } from "@hooks/useGlobalState"; +import { useAppSelector } from "@hooks/useGlobalState"; import { useParams } from "react-router-dom"; import { getChatByID } from "./utils/helpers"; import { useChatMembers } from "./hooks/useChatMember"; import { getElapsedTime } from "@utils/helpers"; -import { useBlock } from "@features/privacy-settings/hooks/useBlock"; -import { setChatIsBlocked } from "@state/messages/chats"; - -const Container = styled.div` +import { useSocket } from "@hooks/useSocket"; +import CallLayout from "@features/calls/CallLayout"; +const Container = styled.div<{ hasMargin?: boolean }>` position: absolute; top: 0; - z-index: 2; height: 3.5rem; width: 100%; - background-color: var(--color-background); display: flex; @@ -30,6 +26,8 @@ const Container = styled.div` align-items: center; padding-inline: 1rem; + + margin: ${({ hasMargin }) => (hasMargin ? "1rem 0" : "0")}; `; const Info = styled.div` @@ -78,42 +76,40 @@ const IconButton = styled.button` justify-content: center; `; -const StyledButton = styled.button` - background: linear-gradient( - to left, - var(--color-background-own-3), - var(--color-background-own-4) - ); - - border: none; - width: 7rem; - height: 2rem; - border-radius: 0.5rem; - color: var(--color-text); - margin-right: 1rem; - - color: var(--color-background); +const InvisibleButton = styled.button` + all: unset; + display: inline-block; + cursor: pointer; `; - //TODO: refactor function Topbar() { const { chatId } = useParams<{ chatId: string }>(); const chats = useAppSelector((state) => state.chats.chats); - const { removeFromBlockList } = useBlock(); - const dispatch = useAppDispatch(); const [isSearching, setIsSearching] = useState(false); - const userId = useAppSelector((state) => state.user.userInfo.id); - const chat = getChatByID({ - chatID: chatId!, - chats: chats, - }); + const [isCollapsed, setIsCollapsed] = useState(false); + const { startConnection } = useSocket(); + const chat = chatId + ? getChatByID({ + chatID: chatId, + chats: chats, + }) + : undefined; const membersData = useChatMembers(chat?.members); + let name; + let image; let lastSeen; if (chat) { - lastSeen = chat?.lastMessage?.timestamp; + if (chat?.type === "group" || chat?.type === "channel") { + name = chat?.name; + } else { + name = membersData[0]?.screenFirstName || membersData[0]?.username; + + lastSeen = chat?.lastMessage?.timestamp; + image = membersData[0]?.photo; + } } if (!chat) return null; @@ -121,59 +117,53 @@ function Topbar() { const toggleSearch = () => { setIsSearching(!isSearching); }; - - async function handleRemoveFromBlock() { - await removeFromBlockList({ id: membersData[0]._id }); - dispatch( - setChatIsBlocked({ - chatId: chatId!, - isBlocked: false, - userId: userId, - }) - ); - } - + const isCall = true; return ( - - - {isSearching ? ( - - ) : ( - <> - - - {chat.name} - {lastSeen && ( - - last seen {getElapsedTime(lastSeen)} - - )} - - - - {chat.isBlocked && ( - - Unblock - - )} - - {getIcon("Call")} - - {getIcon("Search")} - - {getIcon("More")} - - + <> + {isCall && ( + )} - {isSearching && {getIcon("CalendarToday")}} - + + + {isSearching ? ( + + ) : ( + <> + + + {name} + {lastSeen && ( + + last seen {getElapsedTime(lastSeen)} + + )} + + + + + + {getIcon("Call")} + + + + {getIcon("Search")} + + {getIcon("More")} + + + )} + {isSearching && {getIcon("CalendarToday")}} + + ); } diff --git a/app/src/mocks/data/chats.ts b/app/src/mocks/data/chats.ts index 7f9954b8..73a4e4ff 100644 --- a/app/src/mocks/data/chats.ts +++ b/app/src/mocks/data/chats.ts @@ -41,71 +41,71 @@ export type ChatDataType = { }[]; }; -// export const allChats: Chat[] = [ -// { -// _id: "1", -// isSeen: false, -// members: ["1", "2"], -// type: "private", -// numberOfMembers: 1, -// }, -// { -// _id: "2", -// isSeen: false, -// members: ["2", "1"], -// type: "private", -// numberOfMembers: 1, -// }, -// { -// _id: "3", -// isSeen: false, -// members: ["3"], -// type: "private", -// numberOfMembers: 1, -// }, -// { -// _id: "4", -// isSeen: false, -// members: ["4"], -// type: "private", -// numberOfMembers: 1, -// }, -// { -// _id: "5", -// isSeen: false, -// members: ["5"], -// type: "private", -// numberOfMembers: 1, -// }, -// { -// _id: "6", -// isSeen: false, -// members: ["6"], -// type: "private", -// numberOfMembers: 1, -// }, -// { -// _id: "7", -// isSeen: false, -// members: ["7"], -// type: "private", -// numberOfMembers: 1, -// }, -// { -// _id: "8", -// isSeen: false, -// members: ["8"], -// type: "private", -// numberOfMembers: 1, -// }, -// { -// _id: "9", -// isSeen: false, -// members: ["9"], -// type: "private", -// numberOfMembers: 1, -// }, -// ]; +export const allChats: Chat[] = [ + { + _id: "1", + isSeen: false, + members: ["1", "2"], + type: "private", + numberOfMembers: 1, + }, + { + _id: "2", + isSeen: false, + members: ["2", "1"], + type: "private", + numberOfMembers: 1, + }, + { + _id: "3", + isSeen: false, + members: ["3"], + type: "private", + numberOfMembers: 1, + }, + { + _id: "4", + isSeen: false, + members: ["4"], + type: "private", + numberOfMembers: 1, + }, + { + _id: "5", + isSeen: false, + members: ["5"], + type: "private", + numberOfMembers: 1, + }, + { + _id: "6", + isSeen: false, + members: ["6"], + type: "private", + numberOfMembers: 1, + }, + { + _id: "7", + isSeen: false, + members: ["7"], + type: "private", + numberOfMembers: 1, + }, + { + _id: "8", + isSeen: false, + members: ["8"], + type: "private", + numberOfMembers: 1, + }, + { + _id: "9", + isSeen: false, + members: ["9"], + type: "private", + numberOfMembers: 1, + }, +]; export const members: Member[] = [ { diff --git a/app/src/sockets/SocketProvider.tsx b/app/src/sockets/SocketProvider.tsx index 7b26d05d..bbb69158 100644 --- a/app/src/sockets/SocketProvider.tsx +++ b/app/src/sockets/SocketProvider.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, ReactNode } from "react"; +import { useState, useEffect, ReactNode, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { useDispatch } from "react-redux"; import { Dispatch } from "redux"; @@ -15,11 +15,12 @@ import { unpinMessage, editMessage, } from "@state/messages/chats"; +import { connectToPeer, createAnswer, startCall } from "@features/calls/call"; const handleIncomingMessage = ( dispatch: Dispatch, message: MessageInterface, - chatId: string + chatId: string, ) => { dispatch(addMessage({ chatId, message })); }; @@ -27,7 +28,7 @@ const handleIncomingMessage = ( const handleIsTyping = ( dispatch: Dispatch, isTyping: boolean, - chatId: string + chatId: string, ) => { dispatch(setIsTyping({ chatId, isTyping })); }; @@ -92,7 +93,7 @@ function SocketProvider({ children }: SocketProviderProps) { }) => { console.log("UNPIN_MESSAGE_SERVER", chatId, messageId, userId); dispatch(pinMessage({ messageId, chatId })); - } + }, ); socket.on( @@ -108,7 +109,7 @@ function SocketProvider({ children }: SocketProviderProps) { }) => { console.log("UNPIN_MESSAGE_SERVER", chatId, messageId, userId); dispatch(unpinMessage({ messageId, chatId })); - } + }, ); socket.on( @@ -124,7 +125,7 @@ function SocketProvider({ children }: SocketProviderProps) { }) => { console.log("EDIT_MESSAGE_SERVER", chatId, id, content); dispatch(editMessage({ chatId, messageId: id, content })); - } + }, ); socket.on("JOIN_GROUP_CHANNEL", () => { @@ -132,7 +133,7 @@ function SocketProvider({ children }: SocketProviderProps) { }); socket.on("typing", (isTyping, message) => - handleIsTyping(dispatch, isTyping, message.chatId) + handleIsTyping(dispatch, isTyping, message.chatId), ); socket.emit("typing"); @@ -145,7 +146,7 @@ function SocketProvider({ children }: SocketProviderProps) { socket.off("typing"); }; } - }, [dispatch, socket]); + }, [dispatch, queryClient, socket]); const sendMessage = (sentMessage: MessageInterface) => { if (isConnected && socket) { @@ -172,10 +173,10 @@ function SocketProvider({ children }: SocketProviderProps) { ...sentMessage, _id, }, - sentMessage.chatId + sentMessage.chatId, ); } - } + }, ); } else { console.warn("Cannot send message: not connected to socket server"); @@ -185,7 +186,7 @@ function SocketProvider({ children }: SocketProviderProps) { const editMessageSocket = ( messageId: string, content: string, - chatId: string + chatId: string, ) => { if (isConnected && socket) { socket.emit( @@ -203,12 +204,12 @@ function SocketProvider({ children }: SocketProviderProps) { chatId: response.res.message.chatId, messageId: response.res.message._id, content: response.res.message.content, - }) + }), ); } else { console.error("Failed to edit message:", response.error); } - } + }, ); } }; @@ -216,7 +217,7 @@ function SocketProvider({ children }: SocketProviderProps) { const pinMessageSocket = ( chatId: string, messageId: string, - userId: string + userId: string, ) => { if (isConnected && socket) { socket.emit("PIN_MESSAGE_CLIENT", { messageId, chatId, userId }); @@ -228,7 +229,7 @@ function SocketProvider({ children }: SocketProviderProps) { const unpinMessageSocket = ( chatId: string, messageId: string, - userId: string + userId: string, ) => { if (isConnected && socket) { socket.emit("UNPIN_MESSAGE_CLIENT", { messageId, chatId, userId }); @@ -236,6 +237,145 @@ function SocketProvider({ children }: SocketProviderProps) { console.warn("Cannot unpin message: not connected to socket server"); } }; + const startConnection = async () => { + console.log("kkk"); + const offer = await connectToPeer(); + if (isConnected && socket) { + console.log(offer); + socket.emit("SEND_OFFER", { offer }); + } else { + console.warn("Cannot unpin message: not connected to socket server"); + } + }; + const sendAnswer = useCallback( + ( + answer: string, + callback?: (response: { success: boolean; error?: string }) => void, + ) => { + if (isConnected && socket) { + socket.emit( + "SEND_ANSWER", + { answer }, + (response: { success: boolean; error?: string }) => { + if (callback) { + callback(response); + } else if (!response.success) { + console.error("Failed to send answer:", response.error); + } else { + console.log("Answer sent successfully"); + } + }, + ); + } else { + console.warn("Cannot send answer: not connected to socket server"); + } + }, + [isConnected, socket], + ); + useEffect(() => { + if (socket) { + socket.connect(); + + //TODO: remove and make sure it still works + socket.on("connect", () => { + const engine = socket.io.engine; + setIsConnected(true); + console.log("connected"); + + engine.on("close", (reason) => { + console.log(reason); + }); + }); + + socket.on("RECEIVE_MESSAGE", (message) => { + console.log("inside recieve"); + console.log(message); + handleIncomingMessage(dispatch, message, message.chatId); + }); + socket.on("RECIEVE_OFFER", async (offer) => { + console.log(offer); + const answer = await createAnswer(offer); + sendAnswer(answer); + }); + socket.on("RECEIVE_ANSWER", async (answer: string) => { + console.log(answer); + await startCall(answer); + }); + socket.on( + "PIN_MESSAGE_SERVER", + ({ + chatId, + messageId, + userId, + }: { + chatId: string; + messageId: string; + userId: string; + }) => { + console.log("UNPIN_MESSAGE_SERVER", chatId, messageId, userId); + dispatch(pinMessage({ messageId, chatId })); + }, + ); + + socket.on( + "UNPIN_MESSAGE_SERVER", + ({ + chatId, + messageId, + userId, + }: { + chatId: string; + messageId: string; + userId: string; + }) => { + console.log("UNPIN_MESSAGE_SERVER", chatId, messageId, userId); + dispatch(unpinMessage({ messageId, chatId })); + }, + ); + + socket.on( + "EDIT_MESSAGE_SERVER", + ({ + chatId, + content, + id, + }: { + chatId: string; + content: string; + id: string; + }) => { + console.log("EDIT_MESSAGE_SERVER", chatId, id, content); + dispatch(editMessage({ chatId, messageId: id, content })); + }, + ); + + socket.on("typing", (isTyping, message) => + handleIsTyping(dispatch, isTyping, message.chatId), + ); + socket.emit("typing"); + return () => { + socket.disconnect(); + + socket.off("connect"); + socket.off("disconnect"); + socket.off("receive_message"); + socket.off("typing"); + socket.off("RECIEVE_OFFER"); + }; + } + }, [dispatch, sendAnswer, socket]); + + // useEffect(() => { + // if (!isPending && chats?.length) { + // chats.forEach((chat) => { + // socket.emit("join", { chatId: chat._id }); + // }); + // console.log( + // "Joined all chats:", + // chats.map((chat) => chat._id) + // ); + // } + // }, [isConnected, isPending, chats, socket]); function createGroupOrChannel({ type, @@ -260,11 +400,11 @@ function SocketProvider({ children }: SocketProviderProps) { if (!success) { console.log("failed creating group"); } - } + }, ); } else { console.warn( - "Cannot create group or channel: not connected to socket server" + "Cannot create group or channel: not connected to socket server", ); } } @@ -277,6 +417,7 @@ function SocketProvider({ children }: SocketProviderProps) { pinMessage: pinMessageSocket, unpinMessage: unpinMessageSocket, editMessage: editMessageSocket, + startConnection, createGroupOrChannel, }} > diff --git a/app/src/types/socket.ts b/app/src/types/socket.ts index 59029ad4..4ebb96ef 100644 --- a/app/src/types/socket.ts +++ b/app/src/types/socket.ts @@ -16,6 +16,7 @@ export interface SocketContextType { name: string; members: string[]; }) => void; + startConnection: (offer: RTCSessionDescription) => void; } export interface SocketProviderProps {