Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions client/src/infra/rest/apis/chat/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { post, get } from '../..';
import { ApiResponse } from '../../typings';
import { OnlineUser } from './typing';

export const reportPresence = async () => {
return post<undefined, ApiResponse<undefined>>(
'/api/chat/presence',
true,
undefined
);
};

export const getOnlineUsers = async () => {
return get<undefined, ApiResponse<OnlineUser[]>>(
'/api/chat/online-users',
true
);
};
6 changes: 6 additions & 0 deletions client/src/infra/rest/apis/chat/typing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { USER_PERSONAL_LIMITED_INFO } from '../../typings';

export interface OnlineUser {
_id: string;
personal_info: USER_PERSONAL_LIMITED_INFO;
}
4 changes: 4 additions & 0 deletions client/src/modules/chats/v1/constants/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const CHATS_SIDEBAR_WIDTH = 280;

export const ONLINE_USERS_POLL_INTERVAL_MS = 45 * 1000; // 45 seconds
export const PRESENCE_REFRESH_INTERVAL_MS = 60 * 1000; // 1 minute
162 changes: 159 additions & 3 deletions client/src/modules/chats/v1/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,165 @@
import { Box } from '@mui/material';
import { useEffect, useState, useCallback } from 'react';
import {
Box,
List,
ListItemButton,
Avatar,
Typography,
useTheme,
CircularProgress,
} from '@mui/material';
import { getOnlineUsers, reportPresence } from '../../../infra/rest/apis/chat';
import { OnlineUser } from '../../../infra/rest/apis/chat/typing';
import { CHATS_SIDEBAR_WIDTH, ONLINE_USERS_POLL_INTERVAL_MS, PRESENCE_REFRESH_INTERVAL_MS } from './constants';

const Chats = () => {
const theme = useTheme();
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
const [loading, setLoading] = useState(true);
const [selectedUser, setSelectedUser] = useState<OnlineUser | null>(null);

const fetchPresenceAndOnlineUsers = useCallback(async () => {
try {
await reportPresence();
const res = await getOnlineUsers();
if (res.status === 'success' && res.data) {
setOnlineUsers(res.data);
}
} catch {
setOnlineUsers([]);
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
fetchPresenceAndOnlineUsers();
}, [fetchPresenceAndOnlineUsers]);

useEffect(() => {
const pollInterval = setInterval(fetchPresenceAndOnlineUsers, ONLINE_USERS_POLL_INTERVAL_MS);
return () => clearInterval(pollInterval);
}, [fetchPresenceAndOnlineUsers]);

useEffect(() => {
const presenceInterval = setInterval(reportPresence, PRESENCE_REFRESH_INTERVAL_MS);
return () => clearInterval(presenceInterval);
}, []);

return (
<Box>
<p>Chats</p>
<Box
sx={{
height: '100%',
width: '100%',
display: 'flex',
bgcolor: 'background.default',
overflow: 'hidden',
}}
>
<Box
sx={{
width: CHATS_SIDEBAR_WIDTH,
minWidth: CHATS_SIDEBAR_WIDTH,
borderRight: `1px solid ${theme.palette.divider}`,
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Box
sx={{
p: 2,
borderBottom: `1px solid ${theme.palette.divider}`,
}}
>
<Typography variant="h6" component="h1">
Chats
</Typography>
<Typography variant="body2" color="text.secondary">
Logged-in users
</Typography>
</Box>
<Box sx={{ flex: 1, overflow: 'auto' }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={32} />
</Box>
) : onlineUsers.length === 0 ? (
<Box sx={{ px: 2, py: 4 }}>
<Typography variant="body2" color="text.secondary" textAlign="center">
No other users online. Open this page in another browser or ask a friend to open the chat page.
</Typography>
</Box>
) : (
<List disablePadding>
{onlineUsers.map((user) => (
<ListItemButton
key={user._id}
selected={selectedUser?._id === user._id}
onClick={() => setSelectedUser(user)}
sx={{
gap: 1.5,
py: 1.5,
}}
>
<Avatar
src={user.personal_info?.profile_img}
alt={user.personal_info?.fullname}
sx={{ width: 40, height: 40 }}
>
{(user.personal_info?.fullname?.[0] || user.personal_info?.username?.[0] || '?').toUpperCase()}
</Avatar>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="body1" noWrap>
{user.personal_info?.fullname || user.personal_info?.username || 'Unknown'}
</Typography>
<Typography variant="body2" color="text.secondary" noWrap>
@{user.personal_info?.username || '—'}
</Typography>
</Box>
</ListItemButton>
))}
</List>
)}
</Box>
</Box>
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'background.default',
p: 3,
}}
>
{!selectedUser ? (
<Typography variant="body1" color="text.secondary" textAlign="center">
Select a user to start a chat session. Real-time messaging will be available in a future update.
</Typography>
) : (
<Box sx={{ textAlign: 'center', maxWidth: 360 }}>
<Avatar
src={selectedUser.personal_info?.profile_img}
alt={selectedUser.personal_info?.fullname}
sx={{ width: 64, height: 64, mx: 'auto', mb: 1.5 }}
>
{(selectedUser.personal_info?.fullname?.[0] || selectedUser.personal_info?.username?.[0] || '?').toUpperCase()}
</Avatar>
<Typography variant="h6">
Chat with {selectedUser.personal_info?.fullname || selectedUser.personal_info?.username || 'Unknown'}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
@{selectedUser.personal_info?.username || '—'}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Chat session prepared. Real-time messaging will be available in a future update.
</Typography>
</Box>
)}
</Box>
</Box>
);
};
Expand Down
3 changes: 3 additions & 0 deletions client/src/modules/chats/v1/typings/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { OnlineUser } from '../../../infra/rest/apis/chat/typing';

export type SelectedUser = OnlineUser | null;
43 changes: 43 additions & 0 deletions server/src/controllers/chat/get-online-users.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* GET /api/chat/online-users - List users currently online (for chat)
* Excludes the current user. Returns limited profile for display.
*/

import mongoose from 'mongoose';
import USER from '../../models/user.model.js';
import { sendResponse } from '../../utils/response.js';
import { getOnlineUserIds } from '../../stores/online-users.js';

const getOnlineUsers = async (req, res) => {
try {
const { user_id } = req.user;
if (!user_id) {
return sendResponse(res, 401, 'User ID not found in token');
}

const onlineIds = getOnlineUserIds().filter((id) => String(id) !== String(user_id));
if (onlineIds.length === 0) {
return sendResponse(res, 200, 'Online users fetched successfully', []);
}

const objectIds = onlineIds.map((id) => new mongoose.Types.ObjectId(id));
const users = await USER.find({ _id: { $in: objectIds } })
.select('_id personal_info.fullname personal_info.username personal_info.profile_img')
.lean();

const data = users.map((u) => ({
_id: u._id,
personal_info: {
fullname: u.personal_info?.fullname ?? '',
username: u.personal_info?.username ?? '',
profile_img: u.personal_info?.profile_img ?? '',
},
}));

return sendResponse(res, 200, 'Online users fetched successfully', data);
} catch (err) {
return sendResponse(res, 500, err.message || 'Internal Server Error');
}
};

export default getOnlineUsers;
21 changes: 21 additions & 0 deletions server/src/controllers/chat/presence.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* POST /api/chat/presence - Register/refresh current user's presence (e.g. when opening chat page)
*/

import { sendResponse } from '../../utils/response.js';
import { setPresence } from '../../stores/online-users.js';

const presence = async (req, res) => {
try {
const { user_id } = req.user;
if (!user_id) {
return sendResponse(res, 401, 'User ID not found in token');
}
setPresence(String(user_id));
return sendResponse(res, 200, 'Presence updated');
} catch (err) {
return sendResponse(res, 500, err.message || 'Internal Server Error');
}
};

export default presence;
11 changes: 11 additions & 0 deletions server/src/routes/api/chat.routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import express from 'express';
import authenticateUser from '../../middlewares/auth.middleware.js';
import presence from '../../controllers/chat/presence.js';
import getOnlineUsers from '../../controllers/chat/get-online-users.js';

const chatRoutes = express.Router();

chatRoutes.post('/presence', authenticateUser, presence);
chatRoutes.get('/online-users', authenticateUser, getOnlineUsers);

export default chatRoutes;
2 changes: 2 additions & 0 deletions server/src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import notificationRoutes from './api/notification.routes.js';
import collectionRoutes from './api/collections.routes.js';
import collaborationRoutes from './api/collaboration.routes.js';
import feedbackRoutes from './api/feedback.routes.js';
import chatRoutes from './api/chat.routes.js';

const router = express.Router();

Expand All @@ -30,5 +31,6 @@ router.use('/notification', generalLimiter, notificationRoutes);
router.use('/collection', generalLimiter, collectionRoutes);
router.use('/collaboration', generalLimiter, collaborationRoutes);
router.use('/feedback', generalLimiter, feedbackRoutes);
router.use('/chat', generalLimiter, chatRoutes);

export default router;
36 changes: 36 additions & 0 deletions server/src/stores/online-users.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* In-memory store for online (chat) presence.
* Keys: user_id (string), values: lastSeen (number timestamp).
* When Socket.IO is added (sub-issue #1378), this can be replaced by Redis or socket-backed presence.
*/

const PRESENCE_TTL_MS = 2 * 60 * 1000; // 2 minutes

/** @type {Map<string, number>} */
const presenceMap = new Map();

/**
* Mark user as online (refresh lastSeen).
* @param {string} userId
*/
export const setPresence = (userId) => {
if (!userId) return;
presenceMap.set(String(userId), Date.now());
};

/**
* Remove stale entries and return list of user IDs currently considered online.
* @returns {string[]}
*/
export const getOnlineUserIds = () => {
const now = Date.now();
const ids = [];
for (const [id, lastSeen] of presenceMap.entries()) {
if (now - lastSeen < PRESENCE_TTL_MS) {
ids.push(id);
} else {
presenceMap.delete(id);
}
}
return ids;
};