diff --git a/package-lock.json b/package-lock.json index ad64e63..72dd6b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,10 @@ "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.12", @@ -1614,6 +1616,42 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -1806,6 +1844,43 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", diff --git a/package.json b/package.json index f698d77..5e354eb 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,10 @@ "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.12", diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..af3a32a --- /dev/null +++ b/src/app.css @@ -0,0 +1,31 @@ +.riseAndFade { + animation: riseAndFade 5s ease-in-out forwards; +} + +/* Keyframe for rising and fading */ +@keyframes riseAndFade { + 0% { + bottom: 0; + opacity: 1; + } + + 70% { + /* bottom: 70%; */ + opacity: 0.9; + } + + 80% { + /* bottom: 70%; */ + opacity: 0.6; + } + + 90% { + /* bottom: 70%; */ + opacity: 0.4; + } + + 100% { + bottom: 75%; + opacity: 0; + } +} diff --git a/src/assets/emojis/clapping-hand.png b/src/assets/emojis/clapping-hand.png new file mode 100644 index 0000000..593056a Binary files /dev/null and b/src/assets/emojis/clapping-hand.png differ diff --git a/src/assets/emojis/crying-face.png b/src/assets/emojis/crying-face.png new file mode 100644 index 0000000..47bfc71 Binary files /dev/null and b/src/assets/emojis/crying-face.png differ diff --git a/src/assets/emojis/fire.png b/src/assets/emojis/fire.png new file mode 100644 index 0000000..893568a Binary files /dev/null and b/src/assets/emojis/fire.png differ diff --git a/src/assets/emojis/hugging-face.png b/src/assets/emojis/hugging-face.png new file mode 100644 index 0000000..20eb346 Binary files /dev/null and b/src/assets/emojis/hugging-face.png differ diff --git a/src/assets/emojis/party-popper.png b/src/assets/emojis/party-popper.png new file mode 100644 index 0000000..530e114 Binary files /dev/null and b/src/assets/emojis/party-popper.png differ diff --git a/src/assets/emojis/raising-hand.png b/src/assets/emojis/raising-hand.png new file mode 100644 index 0000000..3e5a68b Binary files /dev/null and b/src/assets/emojis/raising-hand.png differ diff --git a/src/assets/emojis/red-heart.png b/src/assets/emojis/red-heart.png new file mode 100644 index 0000000..12644ab Binary files /dev/null and b/src/assets/emojis/red-heart.png differ diff --git a/src/assets/emojis/tears-joy.png b/src/assets/emojis/tears-joy.png new file mode 100644 index 0000000..a9b3d52 Binary files /dev/null and b/src/assets/emojis/tears-joy.png differ diff --git a/src/assets/emojis/thinking-face.png b/src/assets/emojis/thinking-face.png new file mode 100644 index 0000000..59fb4ba Binary files /dev/null and b/src/assets/emojis/thinking-face.png differ diff --git a/src/assets/emojis/thumbs-up.png b/src/assets/emojis/thumbs-up.png new file mode 100644 index 0000000..df8c6d3 Binary files /dev/null and b/src/assets/emojis/thumbs-up.png differ diff --git a/src/assets/index.ts b/src/assets/index.ts index 2aaf048..72f5180 100644 --- a/src/assets/index.ts +++ b/src/assets/index.ts @@ -2,8 +2,31 @@ import emptyChat from './empty-chat.svg'; import mitsiUi from './mitsi-ui.jpg'; import logo from './logo.svg'; +import raisingHand from './emojis/raising-hand.png'; +import thumbsUp from './emojis/thumbs-up.png'; +import clappingHand from './emojis/clapping-hand.png'; +import fire from './emojis/fire.png'; +import partyPopper from './emojis/party-popper.png'; +import huggingFace from './emojis/hugging-face.png'; +import redHeart from './emojis/red-heart.png'; +import tearsJoy from './emojis/tears-joy.png'; +import cryingFace from './emojis/crying-face.png'; +import thinkingFace from './emojis/thinking-face.png'; + export const Assets = { emptyChat, mitsiUi, logo, + + // emoji + raisingHand, + thumbsUp, + clappingHand, + fire, + partyPopper, + huggingFace, + redHeart, + tearsJoy, + cryingFace, + thinkingFace, }; diff --git a/src/components/modals/caution-modal.tsx b/src/components/modals/caution-modal.tsx new file mode 100644 index 0000000..71553cc --- /dev/null +++ b/src/components/modals/caution-modal.tsx @@ -0,0 +1,73 @@ +import { OctagonAlert } from 'lucide-react'; +import { Dialog, DialogContent } from '../ui/dialog'; +import { Button } from '../ui/button'; +import { CautionType } from '@/types'; +import { useCautionActions, useCautionActive } from '@/store/conf/hooks'; + +const CautionModal = () => { + const cautionActive = useCautionActive(); + const cautionActions = useCautionActions(); + + const heading: Record = { + START_RECORDING: 'Start Recording', + STOP_RECORDING: 'Stop Recording', + END_SESSION: 'End Session', + REMOVE_PEER: 'Remove Attendee', + HIDE: '', + }; + + const body: Record = { + START_RECORDING: 'Confirm you want to start recording', + STOP_RECORDING: 'Confirm you want to stop recording', + END_SESSION: 'This will end the meeting for everyone', + REMOVE_PEER: `Are you sure you want to remove peer`, + HIDE: '', + }; + + const okText: Record = { + START_RECORDING: 'Start', + STOP_RECORDING: 'Stop', + END_SESSION: 'End Session', + REMOVE_PEER: 'Remove', + HIDE: '', + }; + + const okAction: Record void> = { + START_RECORDING: () => {}, + STOP_RECORDING: () => {}, + END_SESSION: () => { + window.location.reload(); + }, + REMOVE_PEER: () => {}, + HIDE: () => { + cautionActions.set(CautionType.Hide); + }, + }; + + return ( + cautionActions.set(CautionType.Hide)} + > + +
+
+ + {heading[cautionActive]} +
+ +

{body[cautionActive]}

+ + +
+
+
+ ); +}; + +export default CautionModal; diff --git a/src/components/modals/settings-modal.tsx b/src/components/modals/settings-modal.tsx new file mode 100644 index 0000000..55d6e60 --- /dev/null +++ b/src/components/modals/settings-modal.tsx @@ -0,0 +1,318 @@ +import React, { useState } from 'react'; +import type { FC } from 'react'; +import { + Settings, + Bell, + Video, + Mic, + Volume2, + Users, + LogOut, + MessageSquare, + Hand, + AlertCircle, +} from 'lucide-react'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { + useCameraDeviceId, + useCameraDevices, + useMicDeviceId, + useMicDevices, + useSettingsActions, + useSettingsNotification, + useSettingsOpen, +} from '@/store/conf/hooks'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; +import { useMedia } from '@/hooks/use-media'; +import { Button } from '../ui/button'; + +type TabType = 'device' | 'notifications'; + +interface NotificationItemProps { + label: string; + icon: React.ReactNode; + isEnabled: boolean; + onChange: () => void; +} + +const NotificationToggle: FC = ({ + label, + icon, + isEnabled, + onChange, +}) => ( +
+
+ {icon} + {label} +
+ +
+); + +const DeviceSettings: FC<{ + micVolume: number; + onMicVolumeChange: (volume: number) => void; +}> = ({ micVolume, onMicVolumeChange }) => { + const cameraDeviceId = useCameraDeviceId(); + const cameraDevices = useCameraDevices(); + const micDeviceId = useMicDeviceId(); + const micDevices = useMicDevices(); + + return ( +
+

Device Settings

+ + {/* Video */} +
+ + + +
+ + {/* Microphone */} +
+ + + +
+ + onMicVolumeChange(parseInt(e.target.value))} + className="flex-1 h-1 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> +
+
+ + {/* Speakers */} +
+ +
+ + +
+
+
+ ); +}; + +const NotificationsSettings = () => { + const settingsNotification = useSettingsNotification(); + const settingsActions = useSettingsActions(); + return ( +
+

Notifications

+ +
+ } + isEnabled={settingsNotification.peerJoined} + onChange={() => settingsActions.toggleNotification('peerJoined')} + /> + + } + isEnabled={settingsNotification.peerLeave} + onChange={() => settingsActions.toggleNotification('peerLeave')} + /> + + } + isEnabled={settingsNotification.newMessage} + onChange={() => settingsActions.toggleNotification('newMessage')} + /> + + } + isEnabled={settingsNotification.handRaise} + onChange={() => settingsActions.toggleNotification('handRaise')} + /> + + } + isEnabled={settingsNotification.error} + onChange={() => settingsActions.toggleNotification('error')} + /> +
+
+ ); +}; + +interface TabButtonProps { + isActive: boolean; + onClick: () => void; + icon: React.ReactNode; + label: string; +} + +const TabButton: FC = ({ isActive, onClick, icon, label }) => ( + +); + +const MediaDeviceDropdown = ({ + devices, + selectedDeviceId, + source, +}: { + devices: MediaDeviceInfo[]; + selectedDeviceId: string | null; + source: 'mic' | 'camera'; +}) => { + const { switchDevice } = useMedia(); + + const handleValueChange = React.useCallback( + (value: string) => { + switchDevice(source, value); + }, + [switchDevice, source] + ); + + return ( + + + + + + + {devices.map(device => ( + + {device.label} + + ))} + + + + ); +}; +const SettingsModal: FC = () => { + const settingsOpen = useSettingsOpen(); + const settingsAction = useSettingsActions(); + const [activeTab, setActiveTab] = useState('device'); + const [micVolume, setMicVolume] = useState(65); + + const handleMicVolumeChange = (volume: number): void => { + setMicVolume(volume); + }; + + return ( + + +
+ {/* Left Sidebar */} +
+

Settings

+ + +
+ + {/* Right Content Area */} +
+ {activeTab === 'device' && ( + + )} + + {activeTab === 'notifications' && } +
+
+
+
+ ); +}; + +export default SettingsModal; diff --git a/src/components/room/camera.tsx b/src/components/room/camera.tsx index 285725b..f8224ef 100644 --- a/src/components/room/camera.tsx +++ b/src/components/room/camera.tsx @@ -1,5 +1,5 @@ import { Video, VideoOff } from 'lucide-react'; -import MediaDeviceDropdown from '../media-device-dropdown'; +import MediaDeviceDropdown from './media-device-dropdown'; import { useCameraDeviceId, @@ -7,7 +7,7 @@ import { useCameraOn, } from '@/store/conf/hooks'; import { useMedia } from '@/hooks/use-media'; -import MediaControlButton from '../media-control-button'; +import MediaControlButton from './media-control-button'; const Camera = () => { const { toggleCamera } = useMedia(); diff --git a/src/components/room/chat/chat.tsx b/src/components/room/chat/chat.tsx index 14e61bd..e05d01a 100644 --- a/src/components/room/chat/chat.tsx +++ b/src/components/room/chat/chat.tsx @@ -12,7 +12,7 @@ const Chat = () => { size="icon" className="w-12 h-12 rounded-xl text-white relative bg-linear-to-br from-white/15 to-white/1 backdrop-blur-xl - " + cursor-pointer" > {/* Chat notification */} diff --git a/src/components/room/emoji.tsx b/src/components/room/emoji.tsx index 4ac9697..868b98e 100644 --- a/src/components/room/emoji.tsx +++ b/src/components/room/emoji.tsx @@ -1,18 +1,167 @@ import { Button } from '../ui/button'; import { Smile } from 'lucide-react'; +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; +import { emojiIcons } from '@/lib/utils'; +import type { EmojiNames, EmojiReaction } from '@/types'; +import { usePeerMe, useReactionsActions } from '@/store/conf/hooks'; +import { useSignaling } from '@/hooks/use-signaling'; +import { Actions } from '@/types/actions'; const Emoji = () => { + const { signalingService } = useSignaling(); + const reactionsActions = useReactionsActions(); + const peerMe = usePeerMe(); + const iconSize = 25; + + const sendReactions = (name: EmojiNames) => { + if (!signalingService || !peerMe) return; + const reactions: EmojiReaction = { + id: crypto.randomUUID(), + name: name, + sender: peerMe, + position: `${Math.random() * 80 + 10}%`, + timestamp: Date.now(), + }; + + signalingService.sendMessage({ + action: Actions.SendReaction, + args: { ...reactions }, + }); + reactionsActions.add(reactions); + }; return ( - + + + + + +
+
sendReactions('thumbsUp')} + className=" cursor-pointer w-fit p-1 hover:bg-white/10 rounded-full" + > + Thumbs Up +
+ +
sendReactions('clappingHand')} + className=" cursor-pointer w-fit p-1 hover:bg-white/10 rounded-full" + > + Clapping Hands +
+ +
sendReactions('raisingHand')} + className=" cursor-pointer w-fit p-1 hover:bg-white/10 rounded-full" + > + Raising Hands +
+ +
sendReactions('partyPopper')} + className=" cursor-pointer w-fit p-1 hover:bg-white/10 rounded-full" + > + Party Popper +
+ +
sendReactions('fire')} + className=" cursor-pointer w-fit p-1 hover:bg-white/10 rounded-full" + > + Fire +
+
sendReactions('redHeart')} + className=" cursor-pointer w-fit p-1 hover:bg-white/10 rounded-full" + > + Red Heart +
+ +
sendReactions('huggingFace')} + className=" cursor-pointer w-fit p-1 hover:bg-white/10 rounded-full" + > + Hugging Face +
+
sendReactions('tearsJoy')} + className=" cursor-pointer w-fit p-1 hover:bg-white/10 rounded-full" + > + Face with Tears of Joy +
+ +
sendReactions('cryingFace')} + className=" cursor-pointer w-fit p-1 hover:bg-white/10 rounded-full" + > + Crying Face +
+ +
sendReactions('thinkingFace')} + className=" cursor-pointer w-fit p-1 hover:bg-white/10 rounded-full" + > + Thinking Face +
+
+
+
); }; diff --git a/src/components/room/end.tsx b/src/components/room/end.tsx index 2aa99a6..9a8ea71 100644 --- a/src/components/room/end.tsx +++ b/src/components/room/end.tsx @@ -1,7 +1,18 @@ import { Button } from '../ui/button'; -import { MoreVertical, PhoneOff } from 'lucide-react'; +import { CircleStop, LogOut, MoreVertical, PhoneOff } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; +import { useCautionActions } from '@/store/conf/hooks'; +import { CautionType } from '@/types'; const End = () => { + const cautionActions = useCautionActions(); + const handleLeaveCall = () => { window.location.reload(); }; @@ -15,13 +26,32 @@ const End = () => { > - + + + + + + window.location.reload()} + className="focus:bg-white/8" + > + Leave + + + cautionActions.set(CautionType.EndSession)} + className="focus:bg-white/8" + > + End For All + + + ); }; diff --git a/src/components/room/grid/my-tile.tsx b/src/components/room/grid/my-tile.tsx index 7254e73..1a2273a 100644 --- a/src/components/room/grid/my-tile.tsx +++ b/src/components/room/grid/my-tile.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useRef } from 'react'; -import { Mic, MicOff } from 'lucide-react'; +import { Hand, Mic, MicOff } from 'lucide-react'; import type { Layout } from '@/types'; import { cn, getInitials, getPeerId } from '@/lib/utils'; import { useCameraDeviceId, useCameraOn, + useHandRaised, useMicOn, usePeerConditionsById, usePeerMe, @@ -20,6 +21,7 @@ const MyTile: React.FC = ({ layout }) => { const videoRef = useRef(null); const micOn = useMicOn(); const cameraOn = useCameraOn(); + const handRaised = useHandRaised(); const cameraDeviceId = useCameraDeviceId(); const peerMe = usePeerMe(); const peerMeCondition = usePeerConditionsById(peerMe?.id || getPeerId()); @@ -81,8 +83,9 @@ const MyTile: React.FC = ({ layout }) => { {/* Name Bar */} -
- {peerMe.name} +
+ {handRaised && } + {peerMe.name}
); diff --git a/src/components/room/grid/peer-tile.tsx b/src/components/room/grid/peer-tile.tsx index bca6ce6..02f0ecd 100644 --- a/src/components/room/grid/peer-tile.tsx +++ b/src/components/room/grid/peer-tile.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react'; -import { Mic, MicOff } from 'lucide-react'; +import { Hand, Mic, MicOff } from 'lucide-react'; import type { Layout } from '@/types'; import { cn, getInitials } from '@/lib/utils'; import { @@ -19,7 +19,7 @@ export const PeerTile: React.FC = ({ peerId, layout }) => { const videoRef = useRef(null); const peerData = usePeerOthersById(peerId); const media = usePeerMediasById(peerId); - const peerMeCondition = usePeerConditionsById(peerId); + const peerCondition = usePeerConditionsById(peerId); useEffect(() => { if (!media?.camera || !videoRef.current) return; @@ -39,7 +39,7 @@ export const PeerTile: React.FC = ({ peerId, layout }) => { className={cn( `bg-linear-to-br from-white/5 to-white/2 border border-white/10 backdrop-blur-xl rounded-lg overflow-hidden flex flex-col relative transition-all duration-300 ease-in-out`, - peerMeCondition?.isSpeaking && ' border-blue-500' + peerCondition?.isSpeaking && ' border-blue-500' )} style={{ width: `${layout.width}px`, height: `${layout.height}px` }} > @@ -84,8 +84,9 @@ export const PeerTile: React.FC = ({ peerId, layout }) => { {/* Name Bar */} -
- {peerData.name} +
+ {peerCondition.hand?.raised && } + {peerData.name}
); diff --git a/src/components/room/hand.tsx b/src/components/room/hand.tsx index fc6f8cb..6bb2aa6 100644 --- a/src/components/room/hand.tsx +++ b/src/components/room/hand.tsx @@ -1,23 +1,50 @@ import { Hand as HandIcon } from 'lucide-react'; import { Button } from '../ui/button'; -import { useState } from 'react'; import { cn } from '@/lib/utils'; +import { + useHandActions, + useHandRaised, + usePeerActions, + usePeerMe, +} from '@/store/conf/hooks'; +import { useCallback } from 'react'; +import { useSignaling } from '@/hooks/use-signaling'; +import { Actions } from '@/types/actions'; const Hand = () => { - const [isHandRaised, setIsHandRaised] = useState(false); + const { signalingService } = useSignaling(); + + const handRaised = useHandRaised(); + const handActions = useHandActions(); + const peerActions = usePeerActions(); + const peerMe = usePeerMe(); + + const handleHandRaise = useCallback(() => { + if (!signalingService || !peerMe) return; + signalingService.sendMessage({ + action: Actions.RaiseHand, + args: { raised: !handRaised }, + }); + handActions.toggle(); + peerActions.updateCondition(peerMe.id, { + hand: { + raised: !handRaised, + timestamp: Date.now(), + }, + }); + }, [handRaised, handActions, signalingService, peerMe, peerActions]); return ( diff --git a/src/components/room/join/join-controls.tsx b/src/components/room/join/join-controls.tsx index 38ea84b..86327e6 100644 --- a/src/components/room/join/join-controls.tsx +++ b/src/components/room/join/join-controls.tsx @@ -4,6 +4,7 @@ import { useEffect } from 'react'; import { useMedia } from '@/hooks/use-media'; import Mic from '../mic'; import Camera from '../camera'; +import { useSettingsActions } from '@/store/conf/hooks'; const Controls = () => { const { requestCameraAndMicPermissions } = useMedia(); @@ -11,6 +12,8 @@ const Controls = () => { requestCameraAndMicPermissions(); }, [requestCameraAndMicPermissions]); + const settingsAction = useSettingsActions(); + return (
{/* Microphone Control */} @@ -29,9 +32,10 @@ const Controls = () => { {/* Settings */} diff --git a/src/components/media-control-button.tsx b/src/components/room/media-control-button.tsx similarity index 94% rename from src/components/media-control-button.tsx rename to src/components/room/media-control-button.tsx index 0c9c50b..a65f8ee 100644 --- a/src/components/media-control-button.tsx +++ b/src/components/room/media-control-button.tsx @@ -1,5 +1,5 @@ import { cn } from '@/lib/utils'; -import { Button } from './ui/button'; +import { Button } from '../ui/button'; interface MediaControlButtonProps { isActive: boolean; diff --git a/src/components/media-device-dropdown.tsx b/src/components/room/media-device-dropdown.tsx similarity index 91% rename from src/components/media-device-dropdown.tsx rename to src/components/room/media-device-dropdown.tsx index 9c253e7..70ba9d6 100644 --- a/src/components/media-device-dropdown.tsx +++ b/src/components/room/media-device-dropdown.tsx @@ -39,12 +39,12 @@ const MediaDeviceDropdown = ({ - + Select {source === 'camera' ? source : 'microphone'} @@ -57,6 +57,7 @@ const MediaDeviceDropdown = ({ {device.label} diff --git a/src/components/room/menu.tsx b/src/components/room/menu.tsx index 526dae1..24788e3 100644 --- a/src/components/room/menu.tsx +++ b/src/components/room/menu.tsx @@ -1,18 +1,71 @@ import { Button } from '../ui/button'; -import { Menu as MenuIcon } from 'lucide-react'; +import { + Fullscreen, + // Image, + Menu as MenuIcon, + MessageSquareWarning, + Settings, +} from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; +import { useSettingsActions } from '@/store/conf/hooks'; +import { useCallback, useState } from 'react'; const Menu = () => { + const [fullScreen, setFullScreen] = useState(false); + const settingsAction = useSettingsActions(); + const makeFullScreen = useCallback(() => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + } else if (document.exitFullscreen) { + document.exitFullscreen(); + } + setFullScreen(prev => !prev); + }, []); + return ( - + cursor-pointer" + > + + + + + + {fullScreen ? 'Exit' : 'Go'} Fullscreen + + + Settings + + {/* + Background Effect + */} + + + + window.open('https://github.com/softhon/mitsi-web/issues', '_blank') + } + className="focus:bg-white/8" + > + Issue/Feedback + + + ); }; diff --git a/src/components/room/mic.tsx b/src/components/room/mic.tsx index 1510592..a6ddc7e 100644 --- a/src/components/room/mic.tsx +++ b/src/components/room/mic.tsx @@ -1,9 +1,9 @@ import { Mic as MicOn, MicOff } from 'lucide-react'; import { useMicDeviceId, useMicDevices, useMicOn } from '@/store/conf/hooks'; -import MediaDeviceDropdown from '../media-device-dropdown'; +import MediaDeviceDropdown from './media-device-dropdown'; import { useMedia } from '@/hooks/use-media'; -import MediaControlButton from '../media-control-button'; +import MediaControlButton from './media-control-button'; const Mic = () => { const { toggleMic } = useMedia(); diff --git a/src/components/room/reaction-display.tsx b/src/components/room/reaction-display.tsx new file mode 100644 index 0000000..37a709d --- /dev/null +++ b/src/components/room/reaction-display.tsx @@ -0,0 +1,15 @@ +import { useReactionsEmojis } from '@/store/conf/hooks'; +import ReactionItem from './reaction-item'; + +const ReactionDisplay = () => { + const reactionsEmojis = useReactionsEmojis(); + return ( + <> + {reactionsEmojis.map(reaction => ( + + ))} + + ); +}; + +export default ReactionDisplay; diff --git a/src/components/room/reaction-item.tsx b/src/components/room/reaction-item.tsx new file mode 100644 index 0000000..f17bac5 --- /dev/null +++ b/src/components/room/reaction-item.tsx @@ -0,0 +1,29 @@ +import { cn, emojiIcons } from '@/lib/utils'; +import { usePeerMe } from '@/store/conf/hooks'; +import type { EmojiReaction } from '@/types'; +import { memo } from 'react'; + +const ReactionItem: React.FC = memo( + ({ name, sender, position }) => { + const peerMe = usePeerMe(); + + if (!name) return null; + return ( +
+ {name} + + {peerMe?.id === sender.id ? 'You' : sender.name} + +
+ ); + } +); + +export default ReactionItem; diff --git a/src/components/room/users.tsx b/src/components/room/users.tsx index 9f8e921..b88dcbf 100644 --- a/src/components/room/users.tsx +++ b/src/components/room/users.tsx @@ -13,7 +13,7 @@ const Users = () => { onClick={modalActions.toggleParticipantOpen} variant="ghost" size="icon" - className="w-12 h-12 rounded-xl bg-gradient-to-bl from-white/15 to-white/1 backdrop-blur-xl text-white relative" + className="w-12 h-12 rounded-xl bg-linear-to-bl from-white/15 to-white/1 backdrop-blur-xl text-white relative cursor-pointer" > ) { + return ; +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..6ed8616 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; + +import { cn } from '@/lib/utils'; + +function Popover({ + ...props +}: React.ComponentProps) { + return ; +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function PopoverContent({ + className, + align = 'center', + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return ; +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/src/hooks/use-room.ts b/src/hooks/use-room.ts index 501d4a7..7f34fa6 100644 --- a/src/hooks/use-room.ts +++ b/src/hooks/use-room.ts @@ -2,6 +2,7 @@ import { useChatActions, usePeerActions, usePeerMe, + useReactionsActions, useRoomAccess, useRoomData, } from '@/store/conf/hooks'; @@ -9,6 +10,7 @@ import { useCallback, useMemo } from 'react'; import { Access, type AckCallbackData, + type EmojiReaction, type PeerData, type RoomData, } from '@/types'; @@ -32,6 +34,7 @@ export const useRoom = () => { const roomData = useRoomData(); const roomAccess = useRoomAccess(); const chatActions = useChatActions(); + const reactionsActions = useReactionsActions(); const joinVisitors = useCallback(async () => { if (!signalingService || !roomData) return; @@ -130,6 +133,16 @@ export const useRoom = () => { const data = ValidationSchema.sendChat.parse(args); chatActions.addChat(data); }, + + [Actions.SendReaction]: async args => { + const data = ValidationSchema.sendReaction.parse(args); + reactionsActions.add(data as EmojiReaction); + }, + + [Actions.RaiseHand]: async args => { + const data = ValidationSchema.raiseHand.parse(args); + peerActions.updateCondition(data.peer.id, { hand: data.hand }); + }, }), [ closeConsumer, @@ -138,6 +151,7 @@ export const useRoom = () => { peerActions, resumeConsumer, chatActions, + reactionsActions, ] ); diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 7d4df60..5568361 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -48,4 +48,20 @@ export const ValidationSchema = { receiver: peerDataSchema.optional(), createdAt: z.number(), }), + + sendReaction: z.object({ + id: z.string(), + name: z.string(), + sender: peerDataSchema, + position: z.string(), + timestamp: z.number(), + }), + + raiseHand: z.object({ + peer: peerDataSchema, + hand: z.object({ + raised: z.boolean(), + timestamp: z.number().optional(), + }), + }), }; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 5f3e965..450bf5a 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,7 @@ import { types as mediasoupTypes } from 'mediasoup-client'; import type { + EmojiNames, GridCalculatorConfig, Participant, ProducerSource, @@ -9,6 +10,7 @@ import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; import config from '@/config'; import { v4 as uuidv4 } from 'uuid'; +import { Assets } from '@/assets'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -247,3 +249,16 @@ export const convertTimestampTo12HourTime = (timestamp: number) => { return paddedHours + ':' + paddedMinute + ' ' + period; }; + +export const emojiIcons: Record = { + raisingHand: Assets.raisingHand, + thumbsUp: Assets.thumbsUp, + clappingHand: Assets.clappingHand, + fire: Assets.fire, + partyPopper: Assets.partyPopper, + huggingFace: Assets.huggingFace, + redHeart: Assets.redHeart, + cryingFace: Assets.cryingFace, + tearsJoy: Assets.tearsJoy, + thinkingFace: Assets.thinkingFace, +}; diff --git a/src/main.tsx b/src/main.tsx index 06205dc..daee1a5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,6 @@ import { createRoot } from 'react-dom/client'; import './index.css'; +import './app.css'; import App from './app'; createRoot(document.getElementById('root')!).render(); diff --git a/src/pages/room/conference.tsx b/src/pages/room/conference.tsx index f3642d0..6f09bee 100644 --- a/src/pages/room/conference.tsx +++ b/src/pages/room/conference.tsx @@ -5,6 +5,7 @@ import Header from '@/components/room/header'; import PeerAudioList from '@/components/room/peer-audio-list'; import Display from '@/components/room/display'; import DynamicBg from '@/components/dynamic-bg'; +import ReactionDisplay from '@/components/room/reaction-display'; export const Conference: React.FC = () => { return ( @@ -18,6 +19,7 @@ export const Conference: React.FC = () => { {/* Bottom Controls */} +
); diff --git a/src/pages/room/index.tsx b/src/pages/room/index.tsx index 163c193..e3f3f57 100644 --- a/src/pages/room/index.tsx +++ b/src/pages/room/index.tsx @@ -5,11 +5,12 @@ import { useRoomAccess } from '@/store/conf/hooks'; import { Access } from '@/types'; import Conference from './conference'; import { Helmet } from 'react-helmet'; +import SettingsModal from '@/components/modals/settings-modal'; +import CautionModal from '@/components/modals/caution-modal'; const Room = () => { const roomAccess = useRoomAccess(); - const description = - 'You are invited to meeting on Mitsi conferencing platform to connect and collaborate '; + const description = `You're invited to join meeting on Mitsi - conferencing platform to connect and collaborate`; return ( <> @@ -20,6 +21,8 @@ const Room = () => { {roomAccess === Access.Allowed ? : } + + diff --git a/src/providers/room-provider.tsx b/src/providers/room-provider.tsx index ffa7ac6..4efc25f 100644 --- a/src/providers/room-provider.tsx +++ b/src/providers/room-provider.tsx @@ -89,9 +89,9 @@ const RoomProvider = ({ children }: { children: ReactNode }) => { if (reconnectionToastRef.current) toast.dismiss(reconnectionToastRef.current); - toast.success('You are reconnected', { + toast.success(`Your connection is restored and you're reconnected`, { closeButton: true, - position: 'top-center', + position: 'bottom-center', richColors: true, }); })().catch(err => console.log(err)); @@ -128,9 +128,9 @@ const RoomProvider = ({ children }: { children: ReactNode }) => { toast.dismiss(reconnectionToastRef.current); reconnectionToastRef.current = toast.loading( - 'You are disconnected, attempting to reconnect', + `You're disconnected. Attempting to reconnect`, { - position: 'top-center', + position: 'bottom-center', richColors: true, } ); diff --git a/src/store/conf/hooks.ts b/src/store/conf/hooks.ts index 1ab0d93..9c7e1e6 100644 --- a/src/store/conf/hooks.ts +++ b/src/store/conf/hooks.ts @@ -75,6 +75,7 @@ export const usePeerOthersValues = () => { return Object.values(peerOthers); }, [peerOthers]); }; + export const usePeerMedias = () => useConfStore(state => state.peers.medias); export const usePeerMediasById = (id: string) => useConfStore(state => state.peers.medias[id]); @@ -161,6 +162,7 @@ export const useModalActions = () => // ============================================================================ // CHAT SELECTORS // ============================================================================ + export const useChats = () => useConfStore(state => state.chat.chats); export const useChatActions = () => useMemo( @@ -169,3 +171,61 @@ export const useChatActions = () => }), [] ); + +// ============================================================================ +// SETTINGS SELECTORS +// ============================================================================ + +export const useSettingsOpen = () => useConfStore(state => state.settings.open); +export const useSettingsNotification = () => + useConfStore(state => state.settings.notifications); + +export const useSettingsActions = () => + useMemo( + () => ({ + toggle: useConfStore.getState().settings.toggle, + toggleNotification: useConfStore.getState().settings.toggleNotification, + }), + [] + ); + +// ============================================================================ +// EmojiReactions SELECTORS +// ============================================================================ + +export const useReactionsEmojis = () => + useConfStore(state => state.reactions.emojis); + +export const useReactionsActions = () => + useMemo( + () => ({ + add: useConfStore.getState().reactions.add, + clear: useConfStore.getState().reactions.clear, + }), + [] + ); + +// ============================================================================ +// HAND SELECTORS +// ============================================================================ +export const useHandRaised = () => useConfStore(state => state.hand.raised); +export const useHandActions = () => + useMemo( + () => ({ + toggle: useConfStore.getState().hand.toggle, + }), + [] + ); + +// ============================================================================ +// CAUTION SELECTORS +// ============================================================================ +export const useCautionActive = () => + useConfStore(state => state.caution.active); +export const useCautionActions = () => + useMemo( + () => ({ + set: useConfStore.getState().caution.set, + }), + [] + ); diff --git a/src/store/conf/index.ts b/src/store/conf/index.ts index aaffad0..039b637 100644 --- a/src/store/conf/index.ts +++ b/src/store/conf/index.ts @@ -10,6 +10,10 @@ import { createGridSlice } from './slices/grid-slice'; import { createChatSlice } from './slices/chat-slice'; import { createModalSlice } from './slices/modal-slice'; import { createScreenSlice } from './slices/screen-slice'; +import { createSettingsSlice } from './slices/settings-slice'; +import { createReactionSlice } from './slices/reaction-slice'; +import { createHandSlice } from './slices/hand-slice'; +import { createCautionSlice } from './slices/caution-slice'; export const useConfStore = create()( devtools( @@ -22,6 +26,10 @@ export const useConfStore = create()( chat: createChatSlice(set, get, api), modal: createModalSlice(set, get, api), screen: createScreenSlice(set, get, api), + hand: createHandSlice(set, get, api), + settings: createSettingsSlice(set, get, api), + reactions: createReactionSlice(set, get, api), + caution: createCautionSlice(set, get, api), })), { name: 'conf-store' } ) diff --git a/src/store/conf/slices/caution-slice.ts b/src/store/conf/slices/caution-slice.ts new file mode 100644 index 0000000..6b39c95 --- /dev/null +++ b/src/store/conf/slices/caution-slice.ts @@ -0,0 +1,22 @@ +import type { StateCreator } from 'zustand'; +import type { ConfStoreState } from '../type'; +import { CautionType } from '@/types'; + +export interface CautionSlice { + active: CautionType; + set: (caution: CautionType) => void; +} + +export const createCautionSlice: StateCreator< + ConfStoreState, + [], + [['zustand/immer', CautionSlice]], + CautionSlice +> = set => ({ + active: CautionType.Hide, + set: caution => + set(state => { + state.caution.active = caution; + return state; + }), +}); diff --git a/src/store/conf/slices/hand-slice.ts b/src/store/conf/slices/hand-slice.ts new file mode 100644 index 0000000..3aa115c --- /dev/null +++ b/src/store/conf/slices/hand-slice.ts @@ -0,0 +1,21 @@ +import type { StateCreator } from 'zustand'; +import type { ConfStoreState } from '../type'; + +export interface HandSlice { + raised: boolean; + toggle: () => void; +} + +export const createHandSlice: StateCreator< + ConfStoreState, + [], + [['zustand/immer', HandSlice]], + HandSlice +> = set => ({ + raised: false, + toggle: () => + set(state => { + state.hand.raised = !state.hand.raised; + return state; + }), +}); diff --git a/src/store/conf/slices/reaction-slice.ts b/src/store/conf/slices/reaction-slice.ts new file mode 100644 index 0000000..34f8f17 --- /dev/null +++ b/src/store/conf/slices/reaction-slice.ts @@ -0,0 +1,34 @@ +import type { StateCreator } from 'zustand'; +import type { ConfStoreState } from '../type'; +import type { EmojiReaction } from '@/types'; +const REACTION_EXPIRATION_TIME = 10000; // 10 secs + +export interface ReactionSlice { + emojis: EmojiReaction[]; + add: (emoji: EmojiReaction) => void; + clear: () => void; +} + +export const createReactionSlice: StateCreator< + ConfStoreState, + [], + [['zustand/immer', ReactionSlice]], + ReactionSlice +> = set => ({ + emojis: [], + add: emoji => + set(state => { + const now = Date.now(); + let visible = state.reactions.emojis.filter( + emoji => now - emoji.timestamp < REACTION_EXPIRATION_TIME + ); + visible = visible.length > 15 ? visible.slice(-6) : visible; + state.reactions.emojis = [...visible, emoji]; + return state; + }), + clear: () => + set(state => { + state.reactions.emojis = []; + return state; + }), +}); diff --git a/src/store/conf/slices/settings-slice.ts b/src/store/conf/slices/settings-slice.ts new file mode 100644 index 0000000..d3ad769 --- /dev/null +++ b/src/store/conf/slices/settings-slice.ts @@ -0,0 +1,37 @@ +import type { StateCreator } from 'zustand'; +import type { ConfStoreState } from '../type'; +import type { NotificationSettings } from '@/types'; + +export interface SettingsSlice { + open: boolean; + toggle: () => void; + notifications: NotificationSettings; + toggleNotification: (notification: keyof NotificationSettings) => void; +} + +export const createSettingsSlice: StateCreator< + ConfStoreState, + [], + [['zustand/immer', SettingsSlice]], + SettingsSlice +> = set => ({ + open: false, + toggle: () => + set(state => { + state.settings.open = !state.settings.open; + return state; + }), + notifications: { + peerJoined: false, + peerLeave: false, + newMessage: true, + handRaise: true, + error: true, + }, + toggleNotification: notification => + set(state => { + state.settings.notifications[notification] = + !state.settings.notifications[notification]; + return state; + }), +}); diff --git a/src/store/conf/type.ts b/src/store/conf/type.ts index ad04ebc..9f5f124 100644 --- a/src/store/conf/type.ts +++ b/src/store/conf/type.ts @@ -6,6 +6,10 @@ import type { ModalSlice } from './slices/modal-slice'; import type { PeerSlice } from './slices/peer-slice'; import type { RoomSlice } from './slices/room-slice'; import type { ScreenSlice } from './slices/screen-slice'; +import type { SettingsSlice } from './slices/settings-slice'; +import type { ReactionSlice } from './slices/reaction-slice'; +import type { HandSlice } from './slices/hand-slice'; +import type { CautionSlice } from './slices/caution-slice'; export interface ConfStoreState { mic: MicSlice; @@ -16,4 +20,8 @@ export interface ConfStoreState { chat: ChatSlice; modal: ModalSlice; screen: ScreenSlice; + hand: HandSlice; + settings: SettingsSlice; + reactions: ReactionSlice; + caution: CautionSlice; } diff --git a/src/types/index.ts b/src/types/index.ts index 7af7071..5729e7e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,16 +3,16 @@ import { types as mediasoupTypes } from 'mediasoup-client'; export type ProducerAudioSource = 'mic' | 'screenAudio'; export type ProducerVideoSource = 'camera' | 'screen'; export type ProducerSource = ProducerAudioSource | ProducerVideoSource; -export type ReactionName = +export type EmojiNames = | 'raisingHand' | 'thumbsUp' - | 'clap' + | 'clappingHand' | 'fire' | 'partyPopper' - | 'heartFace' - | 'hugFace' - | 'joy' - | 'cry' + | 'huggingFace' + | 'redHeart' + | 'tearsJoy' + | 'cryingFace' | 'thinkingFace'; export enum Access { @@ -30,11 +30,10 @@ export type AckCallbackData = { response?: T; }; -export enum ActionType { +export enum CautionType { StartRecording = 'START_RECORDING', StopRecording = 'STOP_RECORDING', - LeaveMeeting = 'LEAVE_MEETING', - EndMeeting = 'END_MEETING', + EndSession = 'END_SESSION', RemovePeer = 'REMOVE_PEER', Hide = 'HIDE', } @@ -105,6 +104,10 @@ export interface PeerMedia { export interface PeerCondition { id: string; isSpeaking?: boolean; + hand?: { + raised: boolean; + timestamp?: number; + }; isReconnectiing?: boolean; } @@ -201,3 +204,19 @@ export interface Dimensions { width: number; height: number; } + +export interface NotificationSettings { + peerJoined: boolean; + peerLeave: boolean; + newMessage: boolean; + handRaise: boolean; + error: boolean; +} + +export interface EmojiReaction { + id: string; + name: EmojiNames; + sender: PeerData; + position: `${number}%`; + timestamp: number; +}