663 |
670 | {isCameraSelectorOpen && (
671 | <>
672 | {/* Backdrop to close selector when clicking outside */}
673 |
setIsCameraSelectorOpen(false)}
676 | />
677 |
678 | {devices.map(device => (
679 |
690 | ))}
691 |
692 | >
693 | )}
694 |
695 | );
696 | }
697 |
698 | // Add this icon component
699 | function SwitchCameraIcon() {
700 | return (
701 |
706 | );
707 | }
708 |
709 | // Update the handleEndCall function
710 | const handleEndCall = async () => {
711 | try {
712 | if (sessionId) {
713 | await endSession(sessionId as string);
714 | window.location.reload();
715 | }
716 | } catch (error) {
717 | console.error('Failed to end call:', error);
718 | setError('Failed to end call');
719 | window.location.reload();
720 | }
721 | };
722 |
723 | // Updated session subscription that replaces polling with realtime Firestore updates.
724 | useEffect(() => {
725 | // Ensure we have a valid session id from the router query.
726 | if (!router.query.id) return;
727 | const sessionId = router.query.id as string;
728 | const sessionDocRef = doc(db, 'sessions', sessionId);
729 |
730 | const unsubscribeSession = onSnapshot(sessionDocRef, (docSnap) => {
731 | if (docSnap.exists()) {
732 | const sessionData = docSnap.data();
733 |
734 | // Verify that the session is still active (in_session).
735 | // If the status is no longer 'in_session', redirect with cleanup.
736 | if (sessionData.status !== 'in_session' && sessionData.status !== 'video') {
737 | router.push('/?cleanup=true');
738 | return;
739 | }
740 |
741 | // Update connection status (here using an object; adjust as needed).
742 | setConnectionStatus({ type: 'connected', detail: sessionData.status });
743 |
744 | // Update the partner's peer ID from the session data.
745 | setPartnerPeerId(sessionData.peerIds?.[sessionData.partnerId] || null);
746 |
747 | // Update the session timer if videoTimeLeft is available.
748 | if (sessionData.videoTimeLeft) {
749 | // Assuming videoTimeLeft is in milliseconds.
750 | setTimeLeft(Math.floor(sessionData.videoTimeLeft / 1000));
751 | }
752 |
753 | // (Optional) Update chat messages if they are stored in the session document.
754 | if (sessionData.messages) {
755 | setMessages(
756 | sessionData.messages.map((msg: any) => ({
757 | ...msg,
758 | timestamp: msg.timestamp?.toDate()
759 | }))
760 | );
761 | }
762 | }
763 | });
764 |
765 | // Clean up the subscription on unmount.
766 | return () => unsubscribeSession();
767 | }, [router.query.id, router]);
768 |
769 | // Add this function to send messages
770 | const sendMessage = async (e: React.FormEvent) => {
771 | e.preventDefault();
772 | if (!message.trim() || !user || !sessionId) return;
773 |
774 | try {
775 | const newMessage = {
776 | id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
777 | text: message.trim(),
778 | senderId: user.uid,
779 | timestamp: Timestamp.now()
780 | };
781 |
782 | const sessionRef = doc(db, 'sessions', sessionId as string);
783 | await updateDoc(sessionRef, {
784 | messages: arrayUnion(newMessage)
785 | });
786 |
787 | setMessage('');
788 | } catch (error) {
789 | console.error('Error sending message:', error);
790 | }
791 | };
792 |
793 | // Add this component for the chat toggle button
794 | const ChatToggleButton = () => (
795 |
802 | );
803 |
804 | // Add this icon component
805 | function ChatIcon() {
806 | return (
807 |
810 | );
811 | }
812 |
813 | // For example, logging when the local stream is set up
814 | useEffect(() => {
815 | if (localStream && user && sessionId) {
816 | // logGoogleEvent('local_stream_started', { uid: user.uid, session: sessionId });
817 | }
818 | }, [localStream]);
819 |
820 | return (
821 |
822 |
823 | {/* Main video container */}
824 |
825 |
831 | {remoteIsVideoOff && (
832 |
833 |
834 |
835 | {partnerPeerId?.charAt(0)?.toUpperCase() || '?'}
836 |
837 |
838 |
839 | )}
840 |
841 | {/* Local video pip - moved to top-right on mobile */}
842 |
843 |
850 | {isVideoOff && (
851 |
852 |
853 |
854 | {user?.email?.charAt(0)?.toUpperCase() || '?'}
855 |
856 |
857 |
858 | )}
859 |
860 |
861 | {/* Status indicators - stacked on mobile */}
862 |
863 |
864 | Having issues? Try refreshing the page
865 |
866 |
872 |
877 |
{error || connectionStatus.detail}
878 |
879 | {remoteIsMuted && (
880 |
881 |
882 | Muted
883 |
884 | )}
885 |
886 |
887 | {/* Control bar - adjusted for mobile */}
888 |
889 |
890 | {/* Timer - moved above controls on mobile */}
891 |
892 |
893 | Call ends in
894 |
895 |
896 | {Math.floor(timeLeft / 60)}:{(timeLeft % 60).toString().padStart(2, '0')}
897 |
898 |
899 |
900 | {/* Controls */}
901 |
902 |
910 |
918 | {devices.length > 1 && }
919 |
920 |
927 |
928 |
929 |
930 |
931 |
932 | {/* Chat panel */}
933 | {isChatOpen && (
934 |
935 |
936 |
Chat
937 |
946 |
947 |
948 |
949 | {messages.map((msg) => (
950 |
954 |
960 |
{msg.text}
961 |
965 | {new Date(msg.timestamp).toLocaleTimeString([], {
966 | hour: '2-digit',
967 | minute: '2-digit'
968 | })}
969 |
970 |
971 |
972 | ))}
973 |
974 |
975 |
995 |
996 | )}
997 |
998 |
999 | );
1000 | }
1001 |
1002 | function MicIcon({ muted = false }: { muted?: boolean }) {
1003 | return (
1004 |
1022 | );
1023 | }
1024 |
1025 | function CameraIcon({ disabled = false }: { disabled?: boolean }) {
1026 | return (
1027 |
1041 | );
1042 | }
1043 |
1044 | function EndCallIcon() {
1045 | return (
1046 |
1060 | );
1061 | }
--------------------------------------------------------------------------------
/src/pages/chat/[id].tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useRouter } from 'next/router';
3 | import Layout from '../../components/Layout';
4 | import { useAuth } from '../../contexts/AuthContext';
5 | import { db } from '../../config/firebase';
6 | import { doc, updateDoc, onSnapshot, arrayUnion, Timestamp } from 'firebase/firestore';
7 | import { getMatchmakingStatus } from '../../utils/api';
8 |
9 | // Add loading spinner component
10 | const LoadingSpinner = () => (
11 |
14 | );
15 |
16 | interface Message {
17 | id: string;
18 | text: string;
19 | senderId: string;
20 | timestamp: Date;
21 | }
22 |
23 | export default function ChatPage() {
24 | const router = useRouter();
25 | const { id: sessionId } = router.query;
26 | const { user } = useAuth();
27 | const [message, setMessage] = useState('');
28 | const [messages, setMessages] = useState
([]);
29 | const [partnerId, setPartnerId] = useState(null);
30 | const [cooldownEnds, setCooldownEnds] = useState(null);
31 | const [timeLeft, setTimeLeft] = useState(null);
32 | // Add loading state
33 | const [isLoading, setIsLoading] = useState(true);
34 |
35 | // Fetch session data and messages
36 | useEffect(() => {
37 | if (!user || !sessionId) return;
38 |
39 | const sessionRef = doc(db, 'sessions', sessionId as string);
40 | const unsubscribe = onSnapshot(sessionRef, (doc) => {
41 | if (doc.exists()) {
42 | const sessionData = doc.data();
43 | // Set partner ID
44 | const partner = sessionData.participants.find((p: string) => p !== user.uid);
45 | setPartnerId(partner);
46 |
47 | // Set messages
48 | const sessionMessages = sessionData.messages || [];
49 | setMessages(sessionMessages.map((msg: any) => ({
50 | ...msg,
51 | timestamp: msg.timestamp?.toDate()
52 | })));
53 |
54 | // Check cooldown
55 | if (sessionData.cooldownEnds) {
56 | const cooldownEnd = sessionData.cooldownEnds.toDate();
57 | setCooldownEnds(cooldownEnd);
58 | }
59 |
60 | // Set loading to false once we have the data
61 | setIsLoading(false);
62 | }
63 | });
64 |
65 | return () => unsubscribe();
66 | }, [user, sessionId]);
67 |
68 | // Add timer effect for cooldown
69 | useEffect(() => {
70 | if (!cooldownEnds) return;
71 |
72 | const updateTimer = () => {
73 | const now = new Date();
74 | const remaining = Math.max(0, cooldownEnds.getTime() - now.getTime());
75 | setTimeLeft(remaining);
76 |
77 | if (remaining <= 0) {
78 | // Redirect to home when cooldown ends
79 | router.push('/');
80 | }
81 | };
82 |
83 | const timer = setInterval(updateTimer, 1000);
84 | updateTimer(); // Initial update
85 |
86 | return () => clearInterval(timer);
87 | }, [cooldownEnds, router]);
88 |
89 | // Update the session check effect
90 | useEffect(() => {
91 | if (!user || !sessionId) return;
92 |
93 | const checkSession = async () => {
94 | try {
95 | const status = await getMatchmakingStatus();
96 |
97 | if (status.status === 'ended') {
98 | // Don't wait for router.push to complete
99 | router.push('/').catch(console.error);
100 | return;
101 | }
102 |
103 | // Update time left only if we have a valid chat time remaining
104 | if (status.chatTimeLeft && status.chatTimeLeft > 0) {
105 | const secondsLeft = Math.ceil(status.chatTimeLeft / 1000);
106 | setTimeLeft(secondsLeft);
107 | } else if (status.chatTimeLeft === 0) {
108 | // Don't wait for router.push to complete
109 | router.push('/').catch(console.error);
110 | }
111 | } catch (error) {
112 | console.error('Session check failed:', error);
113 | }
114 | };
115 |
116 | const interval = setInterval(checkSession, 1000);
117 | checkSession();
118 |
119 | return () => clearInterval(interval);
120 | }, [user, sessionId, router]);
121 |
122 | // Add effect to handle session end
123 | useEffect(() => {
124 | if (timeLeft && timeLeft <= 0) {
125 | router.push('/');
126 | }
127 | }, [timeLeft, router]);
128 |
129 | const sendMessage = async (e: React.FormEvent) => {
130 | e.preventDefault();
131 | if (!message.trim() || !user || !sessionId) return;
132 |
133 | try {
134 | const newMessage = {
135 | id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
136 | text: message.trim(),
137 | senderId: user.uid,
138 | timestamp: Timestamp.now()
139 | };
140 |
141 | const sessionRef = doc(db, 'sessions', sessionId as string);
142 | await updateDoc(sessionRef, {
143 | messages: arrayUnion(newMessage)
144 | });
145 |
146 | setMessage('');
147 | } catch (error) {
148 | console.error('Error sending message:', error);
149 | }
150 | };
151 |
152 | // Add a proper time formatting function
153 | const formatTimeLeft = (seconds: number | null) => {
154 | if (seconds === null || seconds <= 0) return "0:00";
155 | const minutes = Math.floor(seconds / 60);
156 | const remainingSeconds = seconds % 60;
157 | return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
158 | };
159 |
160 | return (
161 |
162 | {isLoading ? (
163 |
164 | ) : (
165 |
166 | {timeLeft && timeLeft > 0 && (
167 |
168 |
169 | Chat closes in {formatTimeLeft(timeLeft)}
170 |
171 |
172 | )}
173 |
174 |
175 | {messages.map((msg) => (
176 |
180 |
186 |
{msg.text}
187 | {msg.timestamp && (
188 |
192 | {msg.timestamp && new Date(msg.timestamp).toLocaleTimeString([], {
193 | hour: '2-digit',
194 | minute: '2-digit'
195 | })}
196 |
197 | )}
198 |
199 |
200 | ))}
201 |
202 |
203 |
221 |
222 | )}
223 |
224 | );
225 | }
226 |
227 | const SendIcon = () => (
228 |
231 | );
--------------------------------------------------------------------------------
/src/pages/email.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useAuth } from '../contexts/AuthContext';
3 | import Layout from '../components/Layout';
4 | import { useRouter } from 'next/router';
5 | import Link from 'next/link';
6 |
7 | export default function EmailAuth() {
8 | const [isSignUp, setIsSignUp] = useState(false);
9 | const [email, setEmail] = useState('');
10 | const [password, setPassword] = useState('');
11 | const [confirmPassword, setConfirmPassword] = useState('');
12 | const [error, setError] = useState(null);
13 | const [loading, setLoading] = useState(false);
14 | const { signInWithEmail, signUpWithEmail } = useAuth();
15 | const router = useRouter();
16 |
17 | const handleSubmit = async (e: React.FormEvent) => {
18 | e.preventDefault();
19 | setError(null);
20 | setLoading(true);
21 |
22 | try {
23 | if (isSignUp) {
24 | if (password !== confirmPassword) {
25 | throw new Error('Passwords do not match');
26 | }
27 | await signUpWithEmail(email, password);
28 | } else {
29 | await signInWithEmail(email, password);
30 | }
31 | router.push('/');
32 | } catch (error: any) {
33 | console.error('Authentication error:', error);
34 | setError(error.message || 'Failed to authenticate');
35 | } finally {
36 | setLoading(false);
37 | }
38 | };
39 |
40 | return (
41 |
42 |
43 |
44 |
45 |
46 | {isSignUp ? 'Create an account' : 'Sign in to your account'}
47 |
48 |
49 | {isSignUp ? 'Already have an account?' : "Don't have an account?"}{' '}
50 |
59 |
60 |
61 |
62 | {error && (
63 |
64 | {error}
65 |
66 | )}
67 |
68 |
141 |
142 |
143 |
144 | );
145 | }
--------------------------------------------------------------------------------
/src/pages/fonts/GeistMonoVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ankushKun/random-retarded-3am-project/7f526d8df6c7ba9a245005c20e3e269e71717cdb/src/pages/fonts/GeistMonoVF.woff
--------------------------------------------------------------------------------
/src/pages/fonts/GeistVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ankushKun/random-retarded-3am-project/7f526d8df6c7ba9a245005c20e3e269e71717cdb/src/pages/fonts/GeistVF.woff
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import localFont from "next/font/local";
3 | import { useState, useEffect, useRef } from 'react';
4 | import { useAuth } from '../contexts/AuthContext';
5 | import Layout from '../components/Layout';
6 | import { useRouter } from 'next/router';
7 | import { joinMatchmaking, getMatchmakingStatus, cancelMatchmaking, createMatch, endSession } from '../utils/api';
8 | import ProfileSetup from '../components/ProfileSetup';
9 | import Link from 'next/link';
10 | import { FaGithub, FaXTwitter } from 'react-icons/fa6';
11 | import { doc, onSnapshot, collection, query, where, orderBy, deleteDoc } from 'firebase/firestore';
12 | import { db } from '../config/firebase';
13 |
14 | type MatchmakingStatus = {
15 | status: 'idle' | 'queued' | 'in_session' | 'cooldown' | 'connecting' | 'error' | 'matched';
16 | timeLeft?: number;
17 | sessionId?: string;
18 | cooldownEnd?: number;
19 | queuedAt?: Date;
20 | queuePosition?: number;
21 | totalInQueue?: number;
22 | partnerId?: string;
23 | partnerName?: string;
24 | connectionStatus?: string;
25 | lastUpdated?: number;
26 | activeSessionId?: string;
27 | activeCallsCount?: number;
28 | };
29 |
30 | function FallingHearts() {
31 | const hearts = Array.from({ length: 20 });
32 | return (
33 | <>
34 |
35 | {hearts.map((_, idx) => (
36 |
44 | ❤️
45 |
46 | ))}
47 |
48 |
76 | >
77 | );
78 | }
79 |
80 | export default function Home() {
81 | const { user, signInWithGoogle, profileComplete } = useAuth();
82 | const router = useRouter();
83 | const [isSearching, setIsSearching] = useState(false);
84 | const [error, setError] = useState(null);
85 | const [status, setStatus] = useState({ status: 'idle' });
86 | const [lastStatusUpdate, setLastStatusUpdate] = useState(new Date());
87 | const [connectionStatus, setConnectionStatus] = useState('');
88 | const [isRedirecting, setIsRedirecting] = useState(false);
89 |
90 | // Automatically perform matchmaking if enough users are in queue
91 | const matchmakingCalled = useRef(false);
92 |
93 | useEffect(() => {
94 | const { cleanup } = router.query;
95 | if (cleanup === 'true') {
96 | // Remove the cleanup parameter from the URL
97 | const newUrl = window.location.pathname;
98 | window.history.replaceState({}, '', newUrl);
99 | // Refresh the page
100 | window.location.reload();
101 | }
102 | }, [router.query]);
103 |
104 | const getTimeSinceUpdate = () => {
105 | const seconds = Math.floor((new Date().getTime() - lastStatusUpdate.getTime()) / 1000);
106 | if (seconds < 60) return `${seconds}s ago`;
107 | return `${Math.floor(seconds / 60)}m ${seconds % 60}s ago`;
108 | };
109 |
110 | // Removed polling useEffect that periodically called getMatchmakingStatus
111 | /*
112 | useEffect(() => {
113 | if (!user) return;
114 | const checkStatus = async () => {
115 | try {
116 | if (isRedirecting) return;
117 |
118 | console.log('Checking matchmaking status...');
119 | setConnectionStatus('Checking status...');
120 | const statusData = await getMatchmakingStatus();
121 | console.log('Status received:', statusData);
122 | setStatus(statusData);
123 | setLastStatusUpdate(new Date());
124 | setConnectionStatus('Connected');
125 |
126 | // ... processing API response with switch-case etc.
127 | } catch (error) {
128 | console.error('Status check failed:', error);
129 | setConnectionStatus('Connection lost, retrying...');
130 | setError('Failed to connect to server');
131 | }
132 | };
133 |
134 | const interval = setInterval(checkStatus, 5000);
135 | checkStatus();
136 |
137 | return () => {
138 | clearInterval(interval);
139 | setIsRedirecting(false);
140 | };
141 | }, [user, router, isRedirecting]);
142 | */
143 |
144 | useEffect(() => {
145 | if (!user) return;
146 |
147 | // Set initial connection message
148 | setConnectionStatus('Connecting...');
149 | // Create an array to track all unsubscribe functions
150 | const unsubscribes: Array<() => void> = [];
151 |
152 | // Subscribe to the user's Firestore document to check if they have an active session
153 | const userDocRef = doc(db, 'users', user.uid);
154 | const unsubscribeUser = onSnapshot(userDocRef, (docSnap) => {
155 | const userData = docSnap.data();
156 | if (userData?.activeSession) {
157 | // If an active session exists, update status accordingly
158 | setStatus((prev) => ({ ...prev, status: 'in_session', sessionId: userData.activeSession }));
159 | setConnectionStatus('Match Found');
160 | } else {
161 | // Only override if not already "in_session"
162 | setStatus((prev) => (prev.status !== 'in_session' ? { ...prev, status: 'idle', sessionId: undefined } : prev));
163 | }
164 | });
165 | unsubscribes.push(unsubscribeUser);
166 |
167 | // Subscribe to the current user's matchmaking queue document (if exists)
168 | const userQueueRef = doc(db, 'matchmaking_queue', user.uid);
169 | const unsubscribeUserQueue = onSnapshot(userQueueRef, (docSnap) => {
170 | if (docSnap.exists()) {
171 | const data = docSnap.data();
172 | setStatus((prev) => ({ ...prev, status: 'queued', queuedAt: data.joinedAt.toDate() }));
173 | setConnectionStatus('Queued');
174 | } else {
175 | // If the user is not in the queue and not in a session, set status to idle
176 | setStatus((prev) => (prev.status !== 'in_session' ? { ...prev, status: 'idle' } : prev));
177 | }
178 | });
179 | unsubscribes.push(unsubscribeUserQueue);
180 |
181 | // Subscribe to the entire matchmaking_queue collection (filtered to waiting users)
182 | // to update the total in queue and the user's position in real time.
183 | const queueQuery = query(
184 | collection(db, 'matchmaking_queue'),
185 | where('status', '==', 'waiting'),
186 | orderBy('joinedAt')
187 | );
188 | const unsubscribeQueue = onSnapshot(queueQuery, (snapshot) => {
189 | const totalInQueue = snapshot.size;
190 | let queuePosition: number | undefined = undefined;
191 | snapshot.docs.forEach((docSnap, index) => {
192 | if (docSnap.id === user.uid) {
193 | queuePosition = index + 1;
194 | }
195 | });
196 | setStatus((prev) => ({ ...prev, totalInQueue, queuePosition }));
197 | });
198 | unsubscribes.push(unsubscribeQueue);
199 |
200 | // Subscribe to sessions that are in video or chat phase.
201 | const sessionsQuery = query(
202 | collection(db, 'sessions'),
203 | where('status', 'in', ['video', 'chat'])
204 | );
205 | const unsubscribeSessions = onSnapshot(sessionsQuery, (snapshot) => {
206 | let activeSessionsCount = snapshot.docs.length;
207 | console.log("Active sessions count:", activeSessionsCount);
208 | // snapshot.docs.forEach((docSnap) => {
209 | // const data = docSnap.data();
210 | // if (data.chatEndTime) {
211 | // const chatEndTime = data.chatEndTime.toDate().getTime();
212 | // const now = Date.now();
213 | // if (chatEndTime < now) {
214 | // // Session has expired, remove it from Firestore
215 | // deleteDoc(docSnap.ref).catch((err) =>
216 | // console.error('Failed to delete expired session:', err)
217 | // );
218 | // return; // Skip counting this expired session
219 | // }
220 | // }
221 | // activeSessionsCount++;
222 | // });
223 | const totalUsersInCall = activeSessionsCount * 2;
224 | setStatus((prev) => ({ ...prev, activeCallsCount: totalUsersInCall }));
225 | });
226 | unsubscribes.push(unsubscribeSessions);
227 |
228 | // Clean up all subscriptions when the component unmounts
229 | return () => {
230 | unsubscribes.forEach((unsub) => unsub());
231 | };
232 | }, [user]);
233 |
234 | // Automatically call the match API if enough users are waiting
235 | useEffect(() => {
236 | if (!user) return;
237 | if (
238 | status.status === 'queued' &&
239 | status.totalInQueue &&
240 | status.totalInQueue >= 2 &&
241 | !matchmakingCalled.current
242 | ) {
243 | matchmakingCalled.current = true;
244 | console.log("Automatically calling createMatch API");
245 | // logFirebaseEvent('auto_create_match_initiated', { uid: user?.uid });
246 | createMatch()
247 | .then((response) => {
248 | console.log("Match API response:", response);
249 | if (response.status === 'success' && response.matches && Array.isArray(response.matches)) {
250 | // Find the match that includes the current user
251 | const matchForUser = response.matches.find((match: { participants: string[] }) =>
252 | match.participants.includes(user.uid)
253 | );
254 | if (matchForUser) {
255 | setStatus((prev) => ({
256 | ...prev,
257 | status: 'in_session',
258 | sessionId: matchForUser.sessionId
259 | }));
260 | // logFirebaseEvent('auto_create_match_success', { uid: user?.uid, session: matchForUser.sessionId });
261 | }
262 | }
263 | })
264 | .catch((error) => {
265 | console.error("Error calling match API:", error);
266 | // logFirebaseEvent('auto_create_match_error', { uid: user?.uid, error: error.toString() });
267 | })
268 | .finally(() => {
269 | matchmakingCalled.current = false;
270 | });
271 | }
272 | }, [user, status]);
273 |
274 | const startMatching = async () => {
275 | console.log('Starting matchmaking process...');
276 | // logFirebaseEvent('matchmaking_join_start', { uid: user?.uid });
277 | try {
278 | setError(null);
279 | setIsSearching(true);
280 | const response = await joinMatchmaking();
281 | console.log('Join matchmaking response:', response);
282 | if (response.error) {
283 | console.error('Join matchmaking error:', response.error);
284 | setError(response.error);
285 | setIsSearching(false);
286 | // logFirebaseEvent('matchmaking_join_fail', { uid: user?.uid, error: response.error });
287 | } else {
288 | // logFirebaseEvent('matchmaking_join_success', { uid: user?.uid });
289 | }
290 | } catch (error) {
291 | console.error('Failed to join matchmaking:', error);
292 | setError('Failed to join matchmaking');
293 | setIsSearching(false);
294 | // logFirebaseEvent('matchmaking_join_fail', { uid: user?.uid, error: error instanceof Error ? error.message : 'Unknown error' });
295 | }
296 | };
297 |
298 | const cancelSearch = async () => {
299 | try {
300 | setError(null);
301 | await cancelMatchmaking();
302 | setIsSearching(false);
303 | // logFirebaseEvent('matchmaking_cancel', { uid: user?.uid });
304 | } catch (error) {
305 | console.error('Failed to cancel matchmaking:', error);
306 | setError('Failed to cancel matchmaking');
307 | // logFirebaseEvent('matchmaking_cancel_error', { uid: user?.uid, error: error instanceof Error ? error.message : 'Unknown error' });
308 | }
309 | };
310 |
311 | const formatTime = (ms: number) => {
312 | const seconds = Math.floor(ms / 1000);
313 | const minutes = Math.floor(seconds / 60);
314 | const remainingSeconds = seconds % 60;
315 | return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
316 | };
317 |
318 | const renderQueueStatus = () => {
319 | if (!status) return null;
320 |
321 | return (
322 |
323 |
324 |
325 |
326 | {status.totalInQueue || 0}
327 |
328 |
329 | in queue
330 |
331 |
332 |
333 |
334 | {status.activeCallsCount || 0}
335 |
336 |
337 | in calls
338 |
339 |
340 |
341 | {status.status === 'queued' && (
342 |
343 |
344 |
345 | Your position: {status.queuePosition} of {status.totalInQueue}
346 |
347 |
348 | )}
349 |
350 | );
351 | };
352 |
353 | const renderMainContent = () => {
354 | let content = (
355 |
356 |
357 |
358 | Call Me Maybe 🤙
359 |
360 |
361 |
362 | Meet interesting people through quick video conversations.
363 | No swiping, no endless chats - just real connections.
364 |
365 |
366 | {/* Primary Action Button */}
367 | {user ? (
368 |
369 |
370 | {renderQueueStatus()}
371 |
372 |
389 |
390 | {/* {isSearching && (
391 |
392 |
393 |
399 |
{connectionStatus || 'Connecting...'}
400 |
401 | {status.status === 'queued' && status.queuePosition && (
402 |
403 | Your position: {status.queuePosition} of {status.totalInQueue}
404 |
405 | )}
406 |
412 |
413 | )} */}
414 |
415 |
416 | {error && (
417 |
{error}
418 | )}
419 |
420 | ) : (
421 |
422 |
435 | {/*
439 | Or sign in with email
440 | */}
441 |
442 | )}
443 |
444 | {/* How it Works Section */}
445 | {/*
446 |
447 | How It Works
448 |
449 |
450 |
451 |
452 |
453 | 1
454 |
455 |
456 | Sign Up
457 |
458 |
459 | Quick sign in with Google. No lengthy forms or verification needed.
460 |
461 |
462 |
463 |
464 |
465 |
466 |
467 | 2
468 |
469 |
470 | Get Matched
471 |
472 |
473 | Get matched with someone looking to meet new people.
474 |
475 |
476 |
477 |
478 |
479 |
480 |
481 | 3
482 |
483 |
484 | 15 Min Call
485 |
486 |
487 | Have a meaningful 15-minute video conversation. No pressure, just be yourself.
488 |
489 |
490 |
491 |
492 |
493 |
494 |
495 | 4
496 |
497 |
498 | Connect
499 |
500 |
501 | If you both click, you have 5 minutes to chat or exchange contact.
502 |
503 |
504 |
505 |
506 |
*/}
507 |
508 | {/* Feature Cards */}
509 |
510 |
511 |
512 | ⏱️ Quick Connection
513 |
514 |
515 | 15 minutes to spark a connection. If it clicks, get 5 more minutes to exchange contacts!
516 |
517 |
518 |
519 |
520 |
521 | 🎯 Smart Matching
522 |
523 |
524 | Meet new people and discover unexpected connections through random matching.
525 |
526 |
527 |
528 |
529 |
530 | 🎭 No Games, Just Real
531 |
532 |
533 | Skip the small talk. Have meaningful conversations that matter.
534 |
535 |
536 |
537 |
538 |
539 |
540 |
541 | );
542 |
543 | if (status.status === 'matched') {
544 | content = (
545 | <>
546 | {content}
547 |
548 |
549 |
550 |
551 | 🎉
552 |
553 |
554 | Match Found!
555 |
556 |
557 | You've been matched with{' '}
558 |
559 | {status.partnerName || 'someone'}
560 |
561 |
562 |
563 |
569 |
575 |
576 |
577 |
578 |
579 | >
580 | );
581 | }
582 |
583 | if (status.status === 'in_session') {
584 | content = (
585 | <>
586 | {content}
587 |
588 |
589 |
590 |
591 | ⚠️
592 |
593 |
594 | Match Found
595 |
596 |
597 | You have an active video call with{' '}
598 |
599 | {status.partnerName || 'someone'}
600 |
601 |
602 |
603 |
609 |
623 |
624 |
625 |
626 |
627 | >
628 | );
629 | }
630 |
631 | return content;
632 | };
633 |
634 | const productHuntBadge = () => {
635 | return (
636 |
637 |
638 |

643 |
644 |
645 | );
646 | };
647 |
648 | if (!user) {
649 | return (
650 |
651 |
652 | {productHuntBadge()}
653 |
654 | {renderMainContent()}
655 |
656 | );
657 | }
658 |
659 | if (!profileComplete) {
660 | return (
661 |
662 | window.location.reload()} />
663 |
664 | );
665 | }
666 |
667 | return (
668 |
669 |
670 | {productHuntBadge()}
671 |
672 | {/*
673 |
674 |
675 |
681 |
{connectionStatus}
682 |
683 |
684 | Last updated: {getTimeSinceUpdate()}
685 |
686 |
687 |
*/}
688 | {renderMainContent()}
689 |
690 |
691 |
698 |
699 |
700 |
707 |
708 |
709 |
710 |
711 | );
712 | }
713 |
714 | // New RadarAnimation component to display a radar scanning animation
715 | function RadarAnimation() {
716 | return (
717 |
750 | );
751 | }
752 |
753 | // Updated QueueRadar component to show random profile images for matched people
754 | function QueueRadar() {
755 | const [currentProfile, setCurrentProfile] = useState<{ src: string; top: number; left: number } | null>(null);
756 |
757 | useEffect(() => {
758 | // Sample profile image URLs (simulate random users)
759 | const profiles = [
760 | { src: "https://randomuser.me/api/portraits/women/65.jpg" },
761 | { src: "https://randomuser.me/api/portraits/men/32.jpg" },
762 | { src: "https://randomuser.me/api/portraits/men/15.jpg" },
763 | { src: "https://randomuser.me/api/portraits/women/44.jpg" },
764 | { src: "https://randomuser.me/api/portraits/men/26.jpg" },
765 | { src: "https://randomuser.me/api/portraits/women/12.jpg" }
766 | ];
767 |
768 | let index = 0;
769 | const interval = setInterval(() => {
770 | const newProfile = {
771 | ...profiles[index],
772 | top: Math.random() * 80 + 10, // positions between 10% and 90%
773 | left: Math.random() * 80 + 10,
774 | };
775 | setCurrentProfile(newProfile);
776 | index = (index + 1) % profiles.length;
777 | }, 1000); // Replace current profile every 1 second
778 |
779 | return () => clearInterval(interval);
780 | }, []);
781 |
782 | return (
783 |
784 |
785 |
786 | {currentProfile && (
787 |

793 | )}
794 |
795 |
841 |
842 | );
843 | }
--------------------------------------------------------------------------------
/src/pages/profile.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useAuth } from '../contexts/AuthContext';
3 | import Layout from '../components/Layout';
4 |
5 | export default function Profile() {
6 | const { user, userProfile } = useAuth();
7 | const [loading, setLoading] = useState(true);
8 | const [isEditing, setIsEditing] = useState(false);
9 | const [name, setName] = useState('');
10 | const [gender, setGender] = useState<'male' | 'female'>('male');
11 | const [isSubmitting, setIsSubmitting] = useState(false);
12 | const [error, setError] = useState(null);
13 | const [successMessage, setSuccessMessage] = useState(null);
14 |
15 | useEffect(() => {
16 | if (user && userProfile) {
17 | setName(userProfile.name || '');
18 | setGender(userProfile.gender || 'male');
19 | setLoading(false);
20 | }
21 | }, [user, userProfile]);
22 |
23 | const handleSubmit = async (e: React.FormEvent) => {
24 | e.preventDefault();
25 | setIsSubmitting(true);
26 | setError(null);
27 | setSuccessMessage(null);
28 |
29 | try {
30 | const response = await fetch('/api/profile/update', {
31 | method: 'POST',
32 | headers: {
33 | 'Content-Type': 'application/json',
34 | Authorization: `Bearer ${await user?.getIdToken()}`
35 | },
36 | body: JSON.stringify({
37 | name: name.trim(),
38 | gender,
39 | }),
40 | });
41 |
42 | if (!response.ok) {
43 | const data = await response.json();
44 | throw new Error(data.error || 'Failed to update profile');
45 | }
46 |
47 | setSuccessMessage('Profile updated successfully');
48 | setIsEditing(false);
49 | window.location.reload(); // Reload to update context
50 | } catch (error) {
51 | console.error('Error updating profile:', error);
52 | setError('Failed to update profile. Please try again.');
53 | } finally {
54 | setIsSubmitting(false);
55 | }
56 | };
57 |
58 | if (loading) {
59 | return (
60 |
61 |
64 |
65 | );
66 | }
67 |
68 | return (
69 |
70 |
71 |
72 |
73 |
Profile
74 | {!isEditing && (
75 |
81 | )}
82 |
83 |
84 | {successMessage && (
85 |
86 | {successMessage}
87 |
88 | )}
89 |
90 | {error && (
91 |
92 | {error}
93 |
94 | )}
95 |
96 |
97 |
98 |
99 |
100 | {userProfile?.name?.charAt(0)?.toUpperCase() || user?.email?.charAt(0)?.toUpperCase() || '?'}
101 |
102 |
103 |
104 |
105 | {userProfile?.name || user?.email}
106 |
107 |
108 | Member since {user?.metadata.creationTime ? new Date(user.metadata.creationTime).toLocaleDateString() : 'Unknown'}
109 |
110 |
111 |
112 |
113 | {isEditing ? (
114 |
175 | ) : (
176 |
177 |
178 |
Profile Information
179 |
180 |
181 |
Name
182 |
{userProfile?.name || 'Not set'}
183 |
184 |
185 |
Gender
186 |
{userProfile?.gender || 'Not set'}
187 |
188 |
189 |
190 |
191 |
192 |
Account Details
193 |
194 |
195 |
Email
196 |
{user?.email}
197 |
198 |
199 |
User ID
200 |
{user?.uid}
201 |
202 |
203 |
204 |
205 | )}
206 |
207 |
208 |
209 |
210 | );
211 | }
212 |
--------------------------------------------------------------------------------
/src/pages/tos.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactMarkdown from 'react-markdown';
3 | import fs from 'fs';
4 | import path from 'path';
5 | import Link from 'next/link';
6 |
7 | type ToSProps = {
8 | content: string;
9 | };
10 |
11 | export default function ToS({ content }: ToSProps) {
12 | return (
13 |
14 | {content}
15 |
16 |
17 |
20 |
21 |
22 | );
23 | }
24 |
25 | export async function getStaticProps() {
26 | const filePath = path.join(process.cwd(), 'ToS.md'); // Adjust the path if necessary
27 | const content = fs.readFileSync(filePath, 'utf8');
28 | return {
29 | props: { content },
30 | };
31 | }
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 |
2 | @tailwind base;
3 | @tailwind components;
4 | @tailwind utilities;
5 |
6 | .falling-hearts {
7 | position: fixed;
8 | top: 0;
9 | left: 0;
10 | width: 100%;
11 | height: 100%;
12 | pointer-events: none;
13 | overflow: hidden;
14 | z-index: 9999;
15 | }
16 | .heart {
17 | position: absolute;
18 | top: -50px;
19 | font-size: 2rem;
20 | animation: fall 10s linear infinite;
21 | }
22 | @keyframes fall {
23 | 0% {
24 | transform: translateY(0) rotate(0deg);
25 | opacity: 1;
26 | }
27 | 100% {
28 | transform: translateY(110vh) rotate(360deg);
29 | opacity: 0;
30 | }
31 | }
32 |
33 | .markdown>* {
34 | all: revert !important;
35 | }
--------------------------------------------------------------------------------
/src/utils/api.ts:
--------------------------------------------------------------------------------
1 | import { auth } from '../config/firebase';
2 |
3 | export async function getAuthHeader() {
4 | const token = await auth.currentUser?.getIdToken();
5 | return {
6 | Authorization: `Bearer ${token}`,
7 | 'Content-Type': 'application/json',
8 | };
9 | }
10 |
11 | export async function joinMatchmaking() {
12 | console.log('Sending join matchmaking request');
13 | const res = await fetch('/api/matchmaking/join', {
14 | method: 'POST',
15 | headers: await getAuthHeader(),
16 | });
17 | const data = await res.json();
18 | console.log('Join matchmaking response:', data);
19 | return data;
20 | }
21 |
22 | export async function getMatchmakingStatus() {
23 | console.log('Fetching matchmaking status');
24 | const res = await fetch('/api/matchmaking/status', {
25 | headers: await getAuthHeader(),
26 | });
27 | const data = await res.json();
28 | console.log('Status response:', data);
29 | return data;
30 | }
31 |
32 | export async function createMatch() {
33 | console.log('Sending create match request');
34 | const res = await fetch('/api/matchmaking/match', {
35 | method: 'POST',
36 | headers: await getAuthHeader(),
37 | });
38 | const data = await res.json();
39 | console.log('Create match response:', data);
40 | return data;
41 | }
42 |
43 | export async function cancelMatchmaking() {
44 | const res = await fetch('/api/matchmaking/cancel', {
45 | method: 'POST',
46 | headers: await getAuthHeader(),
47 | });
48 | return res.json();
49 | }
50 |
51 | export async function updatePeerId(sessionId: string, peerId: string | null) {
52 | console.log('Sending update peer ID request:', { sessionId, peerId });
53 | const res = await fetch('/api/sessions/update-peer', {
54 | method: 'POST',
55 | headers: await getAuthHeader(),
56 | body: JSON.stringify({ sessionId, peerId })
57 | });
58 | const data = await res.json();
59 | console.log('Update peer ID response:', data);
60 | return data;
61 | }
62 |
63 | export const endSession = async (sessionId: string) => {
64 | const response = await fetch('/api/matchmaking/end-session', {
65 | method: 'POST',
66 | headers: await getAuthHeader(),
67 | body: JSON.stringify({ sessionId }),
68 | });
69 |
70 | if (!response.ok) {
71 | const error = await response.json();
72 | throw new Error(error.message || 'Failed to end session');
73 | }
74 |
75 | return response.json();
76 | };
--------------------------------------------------------------------------------
/src/utils/media.ts:
--------------------------------------------------------------------------------
1 | let globalStream: MediaStream | null = null;
2 |
3 | export const setGlobalStream = (stream: MediaStream | null) => {
4 | globalStream = stream;
5 | };
6 |
7 | export const getGlobalStream = () => globalStream;
8 |
9 | export const stopMediaStream = () => {
10 | if (globalStream) {
11 | globalStream.getTracks().forEach(track => {
12 | track.stop();
13 | });
14 | globalStream = null;
15 | }
16 | };
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | content: [
5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | colors: {
12 | background: "var(--background)",
13 | foreground: "var(--foreground)",
14 | },
15 | },
16 | },
17 | plugins: [],
18 | };
19 | export default config;
20 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "paths": {
16 | "@/*": ["./src/*"]
17 | }
18 | },
19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
20 | "exclude": ["node_modules"]
21 | }
22 |
--------------------------------------------------------------------------------