From 4fe251a42d95d964152cc9be8b11378464d8dbe2 Mon Sep 17 00:00:00 2001 From: vivek Date: Thu, 19 Feb 2026 22:46:45 +0530 Subject: [PATCH 1/5] refactor: optimize auth listeners and improve reliability in EmbeddedChat --- packages/react/src/hooks/useRCAuth.js | 86 ++++--- packages/react/src/views/ChatBody/ChatBody.js | 59 +++-- .../react/src/views/Message/MessageToolbox.js | 233 ++++++++++-------- .../common/MessageAggregator.js | 24 +- 4 files changed, 232 insertions(+), 170 deletions(-) diff --git a/packages/react/src/hooks/useRCAuth.js b/packages/react/src/hooks/useRCAuth.js index 83b013353b..d905be40c6 100644 --- a/packages/react/src/hooks/useRCAuth.js +++ b/packages/react/src/hooks/useRCAuth.js @@ -25,45 +25,67 @@ export const useRCAuth = () => { const handleLogin = async (userOrEmail, password, code) => { try { const res = await RCInstance.login(userOrEmail, password, code); + + // Handle specific error codes or generic Unauthorized if (res.error === 'Unauthorized' || res.error === 403) { dispatchToastMessage({ type: 'error', - message: - 'Invalid username or password. Please check your credentials and try again', + message: 'Invalid username or password. Please check your credentials.', + }); + return { status: 'error', error: 'Unauthorized' }; + } + + // Handle Two-Factor Authentication (TOTP) + if (res.error === 'totp-required') { + setPassword(password); + setEmailorUser(userOrEmail); + setIsLoginModalOpen(false); + setIsTotpModalOpen(true); + dispatchToastMessage({ + type: 'info', + message: 'MFA Required: Please enter the code from your authenticator app.', }); - } else { - if (res.error === 'totp-required') { - setPassword(password); - setEmailorUser(userOrEmail); - setIsLoginModalOpen(false); - setIsTotpModalOpen(true); - dispatchToastMessage({ - type: 'info', - message: 'Please Open your authentication app and enter the code.', - }); - } else if (res.error === 'totp-invalid') { - dispatchToastMessage({ - type: 'error', - message: 'Invalid TOTP Time-based One-time Password.', - }); - } + return { status: 'totp-required' }; + } + + if (res.error === 'totp-invalid') { + dispatchToastMessage({ + type: 'error', + message: 'Invalid TOTP code. Please try again.', + }); + return { status: 'error', error: 'totp-invalid' }; + } + + // Handle Successful Login + if (res.status === 'success' || (res.me && !res.error)) { + setIsLoginModalOpen(false); + setUserAvatarUrl(res.me.avatarUrl); + setAuthenticatedUserUsername(res.me.username); + setIsUserAuthenticated(true); + setIsTotpModalOpen(false); + + // Clear sensitive temporary data + setEmailorUser(null); + setPassword(null); + + dispatchToastMessage({ + type: 'success', + message: `Welcome back, ${res.me.username}!`, + }); + return { status: 'success', user: res.me }; + } - if (res.status === 'success') { - setIsLoginModalOpen(false); - setUserAvatarUrl(res.me.avatarUrl); - setAuthenticatedUserUsername(res.me.username); - setIsUserAuthenticated(true); - setIsTotpModalOpen(false); - setEmailorUser(null); - setPassword(null); - dispatchToastMessage({ - type: 'success', - message: 'Successfully logged in', - }); - } + // Catch-all for other response errors + if (res.error) { + throw new Error(res.reason || res.error); } } catch (e) { - console.error('A error occurred while setting up user', e); + console.error('Login implementation error:', e); + dispatchToastMessage({ + type: 'error', + message: 'Authentication failed due to a network or server error.', + }); + return { status: 'error', error: e.message }; } }; diff --git a/packages/react/src/views/ChatBody/ChatBody.js b/packages/react/src/views/ChatBody/ChatBody.js index 34f5c8bf40..783cc14cfc 100644 --- a/packages/react/src/views/ChatBody/ChatBody.js +++ b/packages/react/src/views/ChatBody/ChatBody.js @@ -146,43 +146,46 @@ const ChatBody = ({ ); useEffect(() => { - RCInstance.auth.onAuthChange((user) => { - if (user) { - RCInstance.addMessageListener(addMessage); - RCInstance.addMessageDeleteListener(removeMessage); - RCInstance.addActionTriggeredListener(onActionTriggerResponse); - RCInstance.addUiInteractionListener(onActionTriggerResponse); - } - }); - - return () => { + const removeAllListeners = () => { RCInstance.removeMessageListener(addMessage); RCInstance.removeMessageDeleteListener(removeMessage); RCInstance.removeActionTriggeredListener(onActionTriggerResponse); RCInstance.removeUiInteractionListener(onActionTriggerResponse); }; - }, [RCInstance, addMessage, removeMessage, onActionTriggerResponse]); - useEffect(() => { - RCInstance.auth.onAuthChange((user) => { + const unsubscribe = RCInstance.auth.onAuthChange((user) => { if (user) { + // Clear old listeners before adding new ones to avoid duplicates + removeAllListeners(); + RCInstance.addMessageListener(addMessage); + RCInstance.addMessageDeleteListener(removeMessage); + RCInstance.addActionTriggeredListener(onActionTriggerResponse); + RCInstance.addUiInteractionListener(onActionTriggerResponse); + getMessagesAndRoles(); setHasMoreMessages(true); - } else { - getMessagesAndRoles(anonymousMode); - } - }); - }, [RCInstance, anonymousMode, getMessagesAndRoles]); - - useEffect(() => { - RCInstance.auth.onAuthChange((user) => { - if (user) { fetchAndSetPermissions(); } else { + removeAllListeners(); + getMessagesAndRoles(anonymousMode); permissionsRef.current = null; } }); - }, []); + + return () => { + if (typeof unsubscribe === 'function') unsubscribe(); + removeAllListeners(); + }; + }, [ + RCInstance, + addMessage, + removeMessage, + onActionTriggerResponse, + anonymousMode, + getMessagesAndRoles, + fetchAndSetPermissions, + permissionsRef, + ]); // Expose clearUnreadDivider function via ref for ChatInput to call useEffect(() => { @@ -309,9 +312,15 @@ const ChatBody = ({ useEffect(() => { if (messageListRef.current) { - messageListRef.current.scrollTop = messageListRef.current.scrollHeight; + const { scrollTop, scrollHeight, clientHeight } = messageListRef.current; + const isAtBottom = scrollHeight - scrollTop - clientHeight < 100; + const isInitialLoad = messages.length > 0 && scrollTop === 0; + + if (isAtBottom || isInitialLoad) { + messageListRef.current.scrollTop = scrollHeight; + } } - }, [messages]); + }, [messages, messageListRef]); useEffect(() => { checkOverflow(); diff --git a/packages/react/src/views/Message/MessageToolbox.js b/packages/react/src/views/Message/MessageToolbox.js index 75bdc7467d..d24181144f 100644 --- a/packages/react/src/views/Message/MessageToolbox.js +++ b/packages/react/src/views/Message/MessageToolbox.js @@ -1,4 +1,4 @@ -import React, { useState, useContext, useMemo } from 'react'; +import React, { useState, useContext, useMemo } from "react"; import { Box, Modal, @@ -8,16 +8,16 @@ import { useComponentOverrides, appendClassNames, useTheme, -} from '@embeddedchat/ui-elements'; -import RCContext from '../../context/RCInstance'; -import { EmojiPicker } from '../EmojiPicker'; -import { getMessageToolboxStyles } from './Message.styles'; -import SurfaceMenu from '../SurfaceMenu/SurfaceMenu'; -import { Markdown } from '../Markdown'; -import Attachment from '../AttachmentHandler/Attachment'; +} from "@embeddedchat/ui-elements"; +import RCContext from "../../context/RCInstance"; +import { EmojiPicker } from "../EmojiPicker"; +import { getMessageToolboxStyles } from "./Message.styles"; +import SurfaceMenu from "../SurfaceMenu/SurfaceMenu"; +import { Markdown } from "../Markdown"; +import Attachment from "../AttachmentHandler/Attachment"; export const MessageToolbox = ({ - className = '', + className = "", message, variantStyles = {}, style = {}, @@ -42,16 +42,16 @@ export const MessageToolbox = ({ isEditing = false, optionConfig = { surfaceItems: [ - 'reaction', - 'reply', - 'quote', - 'star', - 'copy', - 'link', - 'pin', - 'edit', - 'delete', - 'report', + "reaction", + "reply", + "quote", + "star", + "copy", + "link", + "pin", + "edit", + "delete", + "report", ], menuItems: [], @@ -60,9 +60,9 @@ export const MessageToolbox = ({ ...props }) => { const { styleOverrides, classNames, configOverrides } = useComponentOverrides( - 'MessageToolbox', + "MessageToolbox", className, - style + style, ); const { RCInstance } = useContext(RCContext); const instanceHost = RCInstance.getHost(); @@ -81,121 +81,148 @@ export const MessageToolbox = ({ setShowDeleteModal(false); }; - const isAllowedToPin = userRoles.some((role) => pinRoles.has(role)); + const { + isAllowedToPin, + isAllowedToReport, + isAllowedToEditMessage, + isAllowedToDeleteMessage, + isAllowedToDeleteOwnMessage, + isAllowedToForceDeleteMessage, + isVisibleForMessageType, + canDeleteMessage, + } = useMemo(() => { + const isOwner = message.u._id === authenticatedUserId; + const allowedToPin = userRoles.some((role) => pinRoles.has(role)); + const allowedToReport = !isOwner; + const allowedToEdit = + userRoles.some((role) => editMessageRoles.has(role)) || isOwner; + const allowedToDelete = userRoles.some((role) => + deleteMessageRoles.has(role), + ); + const allowedToDeleteOwn = userRoles.some((role) => + deleteOwnMessageRoles.has(role), + ); + const allowedToForceDelete = userRoles.some((role) => + forceDeleteMessageRoles.has(role), + ); - const isAllowedToReport = message.u._id !== authenticatedUserId; + const visibleForMessageType = + message.files?.[0]?.type !== "audio/mpeg" && + message.files?.[0]?.type !== "video/mp4"; - const isAllowedToEditMessage = userRoles.some((role) => - editMessageRoles.has(role) - ) - ? true - : message.u._id === authenticatedUserId; + const canDelete = allowedToForceDelete + ? true + : allowedToDelete + ? true + : allowedToDeleteOwn + ? isOwner + : false; - const isAllowedToDeleteMessage = userRoles.some((role) => - deleteMessageRoles.has(role) - ); - const isAllowedToDeleteOwnMessage = userRoles.some((role) => - deleteOwnMessageRoles.has(role) - ); - const isAllowedToForceDeleteMessage = userRoles.some((role) => - forceDeleteMessageRoles.has(role) - ); - - const isVisibleForMessageType = - message.files?.[0].type !== 'audio/mpeg' && - message.files?.[0].type !== 'video/mp4'; - - const canDeleteMessage = isAllowedToForceDeleteMessage - ? true - : isAllowedToDeleteMessage - ? true - : isAllowedToDeleteOwnMessage - ? message.u._id === authenticatedUserId - : false; + return { + isAllowedToPin: allowedToPin, + isAllowedToReport: allowedToReport, + isAllowedToEditMessage: allowedToEdit, + isAllowedToDeleteMessage: allowedToDelete, + isAllowedToDeleteOwnMessage: allowedToDeleteOwn, + isAllowedToForceDeleteMessage: allowedToForceDelete, + isVisibleForMessageType: visibleForMessageType, + canDeleteMessage: canDelete, + }; + }, [ + authenticatedUserId, + userRoles, + pinRoles, + deleteMessageRoles, + deleteOwnMessageRoles, + forceDeleteMessageRoles, + editMessageRoles, + message.u._id, + message.files, + ]); const options = useMemo( () => ({ reply: { - label: 'Reply in thread', - id: 'reply', + label: "Reply in thread", + id: "reply", onClick: handleOpenThread(message), - iconName: 'thread', + iconName: "thread", visible: !isThreadMessage, }, quote: { - label: 'Quote', - id: 'quote', + label: "Quote", + id: "quote", onClick: () => handleQuoteMessage(message), - iconName: 'quote', + iconName: "quote", visible: true, }, star: { label: message.starred && message.starred.find((u) => u._id === authenticatedUserId) - ? 'Unstar' - : 'Star', - id: 'star', + ? "Unstar" + : "Star", + id: "star", onClick: () => handleStarMessage(message), iconName: message.starred && message.starred.find((u) => u._id === authenticatedUserId) - ? 'star-filled' - : 'star', + ? "star-filled" + : "star", visible: true, }, reaction: { - label: 'Add reaction', - id: 'reaction', + label: "Add reaction", + id: "reaction", onClick: () => setEmojiOpen(true), - iconName: 'emoji', + iconName: "emoji", visible: true, }, pin: { - label: message.pinned ? 'Unpin' : 'Pin', - id: 'pin', + label: message.pinned ? "Unpin" : "Pin", + id: "pin", onClick: () => handlePinMessage(message), - iconName: message.pinned ? 'pin-filled' : 'pin', + iconName: message.pinned ? "pin-filled" : "pin", visible: isAllowedToPin, }, edit: { - label: 'Edit', - id: 'edit', + label: "Edit", + id: "edit", onClick: () => handleEditMessage(message), - iconName: 'edit', + iconName: "edit", visible: isAllowedToEditMessage, - color: isEditing ? 'secondary' : 'default', + color: isEditing ? "secondary" : "default", ghost: !isEditing, }, copy: { - label: 'Copy message', - id: 'copy', + label: "Copy message", + id: "copy", onClick: () => handleCopyMessage(message), - iconName: 'copy', + iconName: "copy", visible: true, }, link: { - label: 'Copy link', - id: 'link', + label: "Copy link", + id: "link", onClick: () => handleCopyMessageLink(message), - iconName: 'link', + iconName: "link", visible: true, }, delete: { - label: 'Delete', - id: 'delete', + label: "Delete", + id: "delete", onClick: () => setShowDeleteModal(true), - iconName: 'trash', + iconName: "trash", visible: canDeleteMessage, - type: 'destructive', + type: "destructive", }, report: { - label: 'Report', - id: 'report', + label: "Report", + id: "report", onClick: () => handlerReportMessage(message), - iconName: 'report', + iconName: "report", visible: isAllowedToReport, - type: 'destructive', + type: "destructive", }, }), [ @@ -210,8 +237,12 @@ export const MessageToolbox = ({ handleEditMessage, handlerReportMessage, handleCopyMessage, + handleCopyMessageLink, isAllowedToPin, - ] + isAllowedToReport, + isAllowedToEditMessage, + canDeleteMessage, + ], ); const menuOptions = menuItems @@ -248,7 +279,7 @@ export const MessageToolbox = ({ @@ -259,9 +290,9 @@ export const MessageToolbox = ({ )} @@ -288,36 +319,36 @@ export const MessageToolbox = ({ {' '} + style={{ marginRight: "0.5rem" }} + />{" "} Delete this message? {message.file ? ( - message.file.type.startsWith('image/') ? ( + message.file.type.startsWith("image/") ? (
{message.file.name}
{`${message.file.name} (${( message.file.size / 1024 ).toFixed(2)} kB)`}
- ) : message.file.type.startsWith('video/') ? ( + ) : message.file.type.startsWith("video/") ? ( - ) : message.file.type.startsWith('audio/') ? ( -