Skip to content

Commit e1e7b76

Browse files
committed
feat: web notification hooks
1 parent f0ace72 commit e1e7b76

File tree

9 files changed

+263
-7
lines changed

9 files changed

+263
-7
lines changed

frontend/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="UTF-8" />
55
<link rel="icon" type="image/svg+xml" href="/tnc-logo.png" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7-
<title>tnc</title>
7+
<title>TNC-The Nerds Community</title>
88
</head>
99
<body>
1010
<div id="root"></div>

frontend/public/notification.mp3

15.8 KB
Binary file not shown.

frontend/src/App.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,26 @@ import ForgotPassword from "./Pages/ForgotPassword";
77
import VerifyEmail from "./Pages/VerifyEmail";
88
import ResetPassword from "./Pages/ResetPassword";
99
import HomePage from "./Pages/HomePage";
10+
import { useEffect, useState } from "react";
11+
import { useGlobalNotifications } from "./hooks/useGlobalNotifications";
1012

1113
function App() {
14+
const [currentUser, setCurrentUser] = useState<{ _id: string } | null>(null);
15+
const { setCurrentRoom } = useGlobalNotifications(currentUser?._id || null);
16+
17+
// Load current user from localStorage
18+
useEffect(() => {
19+
try {
20+
const raw = localStorage.getItem("authUser");
21+
if (raw) {
22+
const parsed = JSON.parse(raw);
23+
setCurrentUser({ _id: parsed._id });
24+
}
25+
} catch (error) {
26+
console.error("Failed to load user:", error);
27+
}
28+
}, []);
29+
1230
return (
1331
<>
1432
<Routes>
@@ -17,8 +35,8 @@ function App() {
1735
<Route path="/login" element={<LoginPage />} />
1836
<Route path="/forgot-password" element={<ForgotPassword />} />
1937
<Route path="/join-room" element={<JoinActiveRoom />} />
20-
<Route path="/room" element={<ChatInterface />} />
21-
<Route path="/room/:roomId" element={<ChatInterface />} />
38+
<Route path="/room" element={<ChatInterface setCurrentRoom={setCurrentRoom} />} />
39+
<Route path="/room/:roomId" element={<ChatInterface setCurrentRoom={setCurrentRoom} />} />
2240
<Route path="/verify-email" element={<VerifyEmail />} />
2341
<Route path="/reset-password" element={<ResetPassword />} />
2442
</Routes>

frontend/src/Pages/ChatInterface.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import RoomSidebar from "../components/RoomSidebar";
33
import ChatWindow from "../components/ChatWindow";
44
import { useParams } from "react-router-dom";
55

6-
export default function ChatInterface() {
6+
interface ChatInterfaceProps {
7+
setCurrentRoom: (roomId: string | null) => void;
8+
}
9+
10+
export default function ChatInterface({ setCurrentRoom }: ChatInterfaceProps) {
711
const { roomId } = useParams<{ roomId: string }>();
812
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = React.useState(false);
913

@@ -16,6 +20,7 @@ export default function ChatInterface() {
1620
<ChatWindow
1721
roomId={roomId}
1822
onOpenSidebar={() => setIsMobileSidebarOpen(true)}
23+
setCurrentRoom={setCurrentRoom}
1924
/>
2025
</div>
2126
);

frontend/src/components/ChatWindow.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import CodeModal from "./CodeModal";
2020
import ImageModal from "./ImageModal";
2121
import { socket } from "../services/socket";
2222
import axios from "axios";
23+
import { useBrowserNotifications } from "../hooks/useBrowserNotifications";
2324

2425
// --- Types ---
2526
interface User {
@@ -59,6 +60,7 @@ interface RoomDetails {
5960
interface ChatWindowProps {
6061
roomId?: string;
6162
onOpenSidebar?: () => void;
63+
setCurrentRoom: (roomId: string | null) => void;
6264
}
6365

6466
// --- Mock Database (used for fallback) ---
@@ -181,7 +183,7 @@ const MemberModal = ({
181183
);
182184
};
183185

184-
export default function ChatWindow({ roomId, onOpenSidebar }: ChatWindowProps) {
186+
export default function ChatWindow({ roomId, onOpenSidebar, setCurrentRoom }: ChatWindowProps) {
185187
const navigate = useNavigate();
186188
const [isLoading, setIsLoading] = useState(true);
187189
const [activeRoom, setActiveRoom] = useState<RoomDetails | null>(null);
@@ -203,6 +205,9 @@ export default function ChatWindow({ roomId, onOpenSidebar }: ChatWindowProps) {
203205

204206
const [showOptionsMenu, setShowOptionsMenu] = useState(false);
205207

208+
// Browser notifications
209+
const { permission, requestPermission, showNotification } = useBrowserNotifications();
210+
206211
const allMembers = useMemo(() => {
207212
const members: User[] = activeRoom?.members ? [...activeRoom.members] : [];
208213
if (currentUser && !members.find((m) => m.id === currentUser.id)) {
@@ -317,6 +322,22 @@ export default function ChatWindow({ roomId, onOpenSidebar }: ChatWindowProps) {
317322
}
318323
}, []);
319324

325+
// Request notification permission on mount
326+
useEffect(() => {
327+
if (permission === 'default') {
328+
requestPermission();
329+
}
330+
}, [permission, requestPermission]);
331+
332+
// Track current room for global notifications
333+
useEffect(() => {
334+
if (activeRoom?._id) {
335+
setCurrentRoom(activeRoom._id);
336+
} else {
337+
setCurrentRoom(null);
338+
}
339+
}, [activeRoom, setCurrentRoom]);
340+
320341
// Fetch Room & Messages
321342
useEffect(() => {
322343
if (!roomId) {
@@ -415,6 +436,16 @@ export default function ChatWindow({ roomId, onOpenSidebar }: ChatWindowProps) {
415436
newMessages[existingIndex] = formattedMsg;
416437
return newMessages;
417438
}
439+
} else {
440+
// Message from another user - show browser notification
441+
const senderName = newMessage.sender?.name || "Someone";
442+
const messagePreview = newMessage.text || "Sent an image";
443+
444+
showNotification(`${senderName} in #${activeRoom?.title || 'Chat'}`, {
445+
body: messagePreview,
446+
tag: activeRoom?._id || 'chat',
447+
data: { roomId: activeRoom?._id, roomTitle: activeRoom?.title },
448+
});
418449
}
419450
return [...prev, formattedMsg];
420451
});

frontend/src/components/HeroSection.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ export default function HeroSection() {
2828
</div>
2929

3030
<h1 className="text-5xl md:text-7xl font-bold text-white tracking-tight mb-6 animate-in fade-in slide-in-from-bottom-6 duration-700">
31-
The Network for <br />
31+
The Nerds <br />
3232
<span className="text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 to-purple-400">
33-
Creative Developers
33+
Community
3434
</span>
3535
</h1>
3636

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { useState, useEffect, useCallback } from 'react';
2+
3+
export function useBrowserNotifications() {
4+
const [permission, setPermission] = useState<NotificationPermission>('default');
5+
6+
useEffect(() => {
7+
// Check if browser supports notifications
8+
if ('Notification' in window) {
9+
setPermission(Notification.permission);
10+
}
11+
}, []);
12+
13+
const requestPermission = useCallback(async () => {
14+
if (!('Notification' in window)) {
15+
console.log('This browser does not support notifications');
16+
return 'denied';
17+
}
18+
19+
const result = await Notification.requestPermission();
20+
setPermission(result);
21+
return result;
22+
}, []);
23+
24+
const showNotification = useCallback((title: string, options?: NotificationOptions) => {
25+
if (Notification.permission === 'granted') {
26+
const notification = new Notification(title, {
27+
icon: '/tnc-logo.png',
28+
badge: '/tnc-logo.png',
29+
...options,
30+
});
31+
32+
// Auto-close after 5 seconds
33+
setTimeout(() => notification.close(), 5000);
34+
35+
return notification;
36+
}
37+
return null;
38+
}, []);
39+
40+
return {
41+
permission,
42+
requestPermission,
43+
showNotification,
44+
isSupported: 'Notification' in window,
45+
};
46+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { useEffect, useRef } from 'react';
2+
import { socket } from '../services/socket';
3+
import { useBrowserNotifications } from './useBrowserNotifications';
4+
import { useNotificationSound } from './useNotificationSound';
5+
6+
interface UserRoom {
7+
_id: string;
8+
title: string;
9+
roomId: string;
10+
}
11+
12+
export function useGlobalNotifications(userId: string | null) {
13+
const { showNotification, requestPermission } = useBrowserNotifications();
14+
const { playSound } = useNotificationSound();
15+
const userRoomsRef = useRef<UserRoom[]>([]);
16+
const currentRoomRef = useRef<string | null>(null);
17+
18+
// Set current room (to avoid notifying when user is actively in that room)
19+
const setCurrentRoom = (roomId: string | null) => {
20+
currentRoomRef.current = roomId;
21+
};
22+
23+
useEffect(() => {
24+
if (!userId) return;
25+
26+
// Request notification permission
27+
requestPermission();
28+
29+
// Fetch user's rooms and join them via socket
30+
const fetchAndJoinRooms = async () => {
31+
try {
32+
const response = await fetch('/api/room/joined', {
33+
credentials: 'include',
34+
});
35+
36+
if (response.ok) {
37+
const data = await response.json();
38+
const rooms: UserRoom[] = data.rooms;
39+
userRoomsRef.current = rooms;
40+
41+
// Connect socket if not already connected
42+
if (!socket.connected) {
43+
socket.connect();
44+
}
45+
46+
// Join all user's rooms
47+
rooms.forEach((room) => {
48+
socket.emit('join_room', { room: room._id, userId });
49+
});
50+
51+
console.log(`[Global Notifications] Joined ${rooms.length} rooms`);
52+
}
53+
} catch (error) {
54+
console.error('[Global Notifications] Failed to fetch rooms:', error);
55+
}
56+
};
57+
58+
fetchAndJoinRooms();
59+
60+
// Listen for messages from ALL rooms
61+
const handleGlobalMessage = (message: any) => {
62+
const senderId = message.sender?._id || message.sender;
63+
const roomId = message.room;
64+
65+
// Don't notify if:
66+
// 1. Message is from current user
67+
// 2. User is currently viewing this room
68+
if (senderId === userId || roomId === currentRoomRef.current) {
69+
return;
70+
}
71+
72+
// Find room info
73+
const room = userRoomsRef.current.find((r) => r._id === roomId);
74+
const roomTitle = room?.title || 'Chat';
75+
const senderName = message.sender?.name || 'Someone';
76+
const messageText = message.text || 'Sent an image';
77+
78+
// Play sound
79+
playSound();
80+
81+
// Show browser notification
82+
showNotification(`${senderName} in #${roomTitle}`, {
83+
body: messageText,
84+
icon: '/tnc-logo.png',
85+
tag: roomId,
86+
data: { roomId, roomTitle },
87+
});
88+
};
89+
90+
socket.on('receive_message', handleGlobalMessage);
91+
socket.on('received_message', handleGlobalMessage);
92+
93+
return () => {
94+
socket.off('receive_message', handleGlobalMessage);
95+
socket.off('received_message', handleGlobalMessage);
96+
};
97+
}, [userId, showNotification, playSound, requestPermission]);
98+
99+
return { setCurrentRoom };
100+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { useCallback, useRef, useEffect } from 'react';
2+
3+
export function useNotificationSound() {
4+
const audioRef = useRef<HTMLAudioElement | null>(null);
5+
const isEnabledRef = useRef(false);
6+
7+
// Initialize audio element
8+
const initAudio = useCallback(() => {
9+
if (!audioRef.current) {
10+
audioRef.current = new Audio('/notification.mp3');
11+
audioRef.current.volume = 0.9;
12+
}
13+
}, []);
14+
15+
// Enable audio on first user interaction
16+
useEffect(() => {
17+
const enableAudio = () => {
18+
if (!isEnabledRef.current) {
19+
initAudio();
20+
// Play and immediately pause to "unlock" audio
21+
audioRef.current?.play().then(() => {
22+
audioRef.current?.pause();
23+
audioRef.current!.currentTime = 0;
24+
isEnabledRef.current = true;
25+
}).catch(() => {
26+
// Ignore errors during initialization
27+
});
28+
}
29+
};
30+
31+
// Listen for any user interaction
32+
document.addEventListener('click', enableAudio, { once: true });
33+
document.addEventListener('keydown', enableAudio, { once: true });
34+
35+
return () => {
36+
document.removeEventListener('click', enableAudio);
37+
document.removeEventListener('keydown', enableAudio);
38+
};
39+
}, [initAudio]);
40+
41+
const playSound = useCallback(() => {
42+
initAudio();
43+
44+
if (audioRef.current) {
45+
// Reset to start if already playing
46+
audioRef.current.currentTime = 0;
47+
48+
// Play the sound
49+
audioRef.current.play().catch(() => {
50+
// Silently fail if audio can't play
51+
});
52+
}
53+
}, [initAudio]);
54+
55+
return { playSound };
56+
}

0 commit comments

Comments
 (0)