From d2d583798c883e10ae8d5e61102d3a6f10703be2 Mon Sep 17 00:00:00 2001 From: ayush123-4 Date: Thu, 12 Feb 2026 11:46:50 +0530 Subject: [PATCH] Display list of logged-in users in chat UI #1379 --- client/src/infra/rest/apis/chat/index.ts | 18 ++ client/src/infra/rest/apis/chat/typing.ts | 6 + .../src/modules/chats/v1/constants/index.ts | 4 + client/src/modules/chats/v1/index.tsx | 162 +++++++++++++++++- client/src/modules/chats/v1/typings/index.ts | 3 + .../src/controllers/chat/get-online-users.js | 43 +++++ server/src/controllers/chat/presence.js | 21 +++ server/src/routes/api/chat.routes.js | 11 ++ server/src/routes/index.js | 2 + server/src/stores/online-users.js | 36 ++++ 10 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 client/src/infra/rest/apis/chat/index.ts create mode 100644 client/src/infra/rest/apis/chat/typing.ts create mode 100644 client/src/modules/chats/v1/constants/index.ts create mode 100644 client/src/modules/chats/v1/typings/index.ts create mode 100644 server/src/controllers/chat/get-online-users.js create mode 100644 server/src/controllers/chat/presence.js create mode 100644 server/src/routes/api/chat.routes.js create mode 100644 server/src/stores/online-users.js diff --git a/client/src/infra/rest/apis/chat/index.ts b/client/src/infra/rest/apis/chat/index.ts new file mode 100644 index 00000000..b876ed78 --- /dev/null +++ b/client/src/infra/rest/apis/chat/index.ts @@ -0,0 +1,18 @@ +import { post, get } from '../..'; +import { ApiResponse } from '../../typings'; +import { OnlineUser } from './typing'; + +export const reportPresence = async () => { + return post>( + '/api/chat/presence', + true, + undefined + ); +}; + +export const getOnlineUsers = async () => { + return get>( + '/api/chat/online-users', + true + ); +}; diff --git a/client/src/infra/rest/apis/chat/typing.ts b/client/src/infra/rest/apis/chat/typing.ts new file mode 100644 index 00000000..b20859a4 --- /dev/null +++ b/client/src/infra/rest/apis/chat/typing.ts @@ -0,0 +1,6 @@ +import { USER_PERSONAL_LIMITED_INFO } from '../../typings'; + +export interface OnlineUser { + _id: string; + personal_info: USER_PERSONAL_LIMITED_INFO; +} diff --git a/client/src/modules/chats/v1/constants/index.ts b/client/src/modules/chats/v1/constants/index.ts new file mode 100644 index 00000000..288c7a8c --- /dev/null +++ b/client/src/modules/chats/v1/constants/index.ts @@ -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 diff --git a/client/src/modules/chats/v1/index.tsx b/client/src/modules/chats/v1/index.tsx index f6dc52e6..6d2a2cd9 100644 --- a/client/src/modules/chats/v1/index.tsx +++ b/client/src/modules/chats/v1/index.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [selectedUser, setSelectedUser] = useState(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 ( - -

Chats

+ + + + + Chats + + + Logged-in users + + + + {loading ? ( + + + + ) : onlineUsers.length === 0 ? ( + + + No other users online. Open this page in another browser or ask a friend to open the chat page. + + + ) : ( + + {onlineUsers.map((user) => ( + setSelectedUser(user)} + sx={{ + gap: 1.5, + py: 1.5, + }} + > + + {(user.personal_info?.fullname?.[0] || user.personal_info?.username?.[0] || '?').toUpperCase()} + + + + {user.personal_info?.fullname || user.personal_info?.username || 'Unknown'} + + + @{user.personal_info?.username || '—'} + + + + ))} + + )} + + + + {!selectedUser ? ( + + Select a user to start a chat session. Real-time messaging will be available in a future update. + + ) : ( + + + {(selectedUser.personal_info?.fullname?.[0] || selectedUser.personal_info?.username?.[0] || '?').toUpperCase()} + + + Chat with {selectedUser.personal_info?.fullname || selectedUser.personal_info?.username || 'Unknown'} + + + @{selectedUser.personal_info?.username || '—'} + + + Chat session prepared. Real-time messaging will be available in a future update. + + + )} + ); }; diff --git a/client/src/modules/chats/v1/typings/index.ts b/client/src/modules/chats/v1/typings/index.ts new file mode 100644 index 00000000..4e994c8d --- /dev/null +++ b/client/src/modules/chats/v1/typings/index.ts @@ -0,0 +1,3 @@ +import { OnlineUser } from '../../../infra/rest/apis/chat/typing'; + +export type SelectedUser = OnlineUser | null; diff --git a/server/src/controllers/chat/get-online-users.js b/server/src/controllers/chat/get-online-users.js new file mode 100644 index 00000000..ca811356 --- /dev/null +++ b/server/src/controllers/chat/get-online-users.js @@ -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; diff --git a/server/src/controllers/chat/presence.js b/server/src/controllers/chat/presence.js new file mode 100644 index 00000000..c046712e --- /dev/null +++ b/server/src/controllers/chat/presence.js @@ -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; diff --git a/server/src/routes/api/chat.routes.js b/server/src/routes/api/chat.routes.js new file mode 100644 index 00000000..c023e8cb --- /dev/null +++ b/server/src/routes/api/chat.routes.js @@ -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; diff --git a/server/src/routes/index.js b/server/src/routes/index.js index d0a675f0..4dd1c79c 100644 --- a/server/src/routes/index.js +++ b/server/src/routes/index.js @@ -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(); @@ -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; diff --git a/server/src/stores/online-users.js b/server/src/stores/online-users.js new file mode 100644 index 00000000..e5b55038 --- /dev/null +++ b/server/src/stores/online-users.js @@ -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} */ +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; +};