diff --git a/.nvmrc b/.nvmrc index d60d573ec6..5a60199693 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v16.19.0 \ No newline at end of file +v22.16.0 \ No newline at end of file diff --git a/package.json b/package.json index c589243fe2..08d35fb109 100644 --- a/package.json +++ b/package.json @@ -27,5 +27,9 @@ "dependencies": { "dompurify": "^3.1.6", "validator": "^13.15.15" + }, + "resolutions": { + "react": "^19.0.0", + "react-dom": "^19.0.0" } } diff --git a/packages/api/src/EmbeddedChatApi.ts b/packages/api/src/EmbeddedChatApi.ts index 88eb4c23c2..3c5c9a8b1c 100644 --- a/packages/api/src/EmbeddedChatApi.ts +++ b/packages/api/src/EmbeddedChatApi.ts @@ -20,6 +20,7 @@ export default class EmbeddedChatApi { onUiInteractionCallbacks: ((data: any) => void)[]; typingUsers: string[]; auth: RocketChatAuth; + private _connectPromise: Promise | null = null; constructor( host: string, @@ -186,6 +187,17 @@ export default class EmbeddedChatApi { * TODO: Add logic to call thread message event listeners. To be done after thread implementation */ async connect() { + // Guard against concurrent connect() calls (e.g. React StrictMode double-invoke) + if (this._connectPromise) { + return this._connectPromise; + } + this._connectPromise = this._doConnect().finally(() => { + this._connectPromise = null; + }); + return this._connectPromise; + } + + private async _doConnect() { try { await this.close(); // before connection, all previous subscriptions should be cancelled await this.rcClient.connect({}); @@ -198,7 +210,6 @@ export default class EmbeddedChatApi { } const message = JSON.parse(JSON.stringify(data)); if (message.ts?.$date) { - console.log(message.ts?.$date); message.ts = message.ts.$date; } if (!message.ts) { @@ -683,14 +694,14 @@ export default class EmbeddedChatApi { async sendTypingStatus(username: string, typing: boolean) { try { - this.rcClient.methodCall( + await this.rcClient.methodCall( "stream-notify-room", `${this.rid}/user-activity`, username, typing ? ["user-typing"] : [] ); } catch (err) { - console.error(err); + // DDP typing indicator fails when connection is temporarily down — expected, safe to ignore } } diff --git a/packages/auth/src/Api.ts b/packages/auth/src/Api.ts index 78d6c82c7a..f8967ff486 100644 --- a/packages/auth/src/Api.ts +++ b/packages/auth/src/Api.ts @@ -44,7 +44,8 @@ export class Api { if (!response.ok) { throw new ApiError(response, "Failed Api Request for " + endpoint); } - const jsonData = await response.json(); + const text = await response.text(); + const jsonData = text.length ? JSON.parse(text) : {}; return { data: jsonData }; } diff --git a/packages/auth/src/RocketChatAuth.ts b/packages/auth/src/RocketChatAuth.ts index 0f2c55f196..769ca55778 100644 --- a/packages/auth/src/RocketChatAuth.ts +++ b/packages/auth/src/RocketChatAuth.ts @@ -36,7 +36,9 @@ class RocketChatAuth { async onAuthChange(callback: (user: object | null) => void) { this.authListeners.push(callback); const user = await this.getCurrentUser(); - callback(user); + if (this.authListeners.includes(callback)) { + callback(user); + } } async removeAuthListener(callback: (user: object | null) => void) { @@ -72,6 +74,7 @@ class RocketChatAuth { } ); this.setUser(response.data); + this.notifyAuthListeners(); return this.currentUser; } @@ -92,6 +95,7 @@ class RocketChatAuth { credentials ); this.setUser(response.data); + this.notifyAuthListeners(); return this.currentUser; } @@ -107,6 +111,7 @@ class RocketChatAuth { api: this.api, }); this.setUser(response.data); + this.notifyAuthListeners(); return this.currentUser; } @@ -190,10 +195,10 @@ class RocketChatAuth { try { const token = await this.getToken(); if (token) { - const user = await this.loginWithResumeToken(token); // will notifyAuthListeners on successful login + const user = await this.loginWithResumeToken(token); if (user) { this.lastFetched = new Date(); - await this.getCurrentUser(); // refresh the token if needed + await this.getCurrentUser(); } } } catch (e) { diff --git a/packages/docs/package.json b/packages/docs/package.json index f14a34ce8a..194749f2c9 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -19,8 +19,8 @@ "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", - "react": "^18.0.0", - "react-dom": "^18.0.0" + "react": "^19.0.0", + "react-dom": "^19.0.0" }, "devDependencies": { "@docusaurus/module-type-aliases": "3.5.1", diff --git a/packages/e2e-react/package.json b/packages/e2e-react/package.json index 1e34c0276c..d9d96ffb4c 100644 --- a/packages/e2e-react/package.json +++ b/packages/e2e-react/package.json @@ -14,14 +14,14 @@ }, "dependencies": { "@embeddedchat/react": "workspace:*", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^19.0.0", + "react-dom": "^19.0.0" }, "devDependencies": { "@playwright/test": "^1.41.2", "@types/node": "^20.11.19", - "@types/react": "^18.2.55", - "@types/react-dom": "^18.2.19", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@vitejs/plugin-react": "^4.2.1", diff --git a/packages/htmlembed/package.json b/packages/htmlembed/package.json index 82beecd43a..9d433d5554 100644 --- a/packages/htmlembed/package.json +++ b/packages/htmlembed/package.json @@ -14,8 +14,8 @@ }, "dependencies": { "@embeddedchat/react": "workspace:^", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^19.0.0", + "react-dom": "^19.0.0" }, "devDependencies": { "@vitejs/plugin-react": "^3.1.0", diff --git a/packages/layout_editor/package.json b/packages/layout_editor/package.json index e8e9827e39..5cc6d09171 100644 --- a/packages/layout_editor/package.json +++ b/packages/layout_editor/package.json @@ -14,9 +14,9 @@ "@dnd-kit/sortable": "^8.0.0", "@embeddedchat/markups": "workspace:^", "@embeddedchat/ui-elements": "workspace:^", - "react": "^18.2.0", + "react": "^19.0.0", "react-color": "^2.19.3", - "react-dom": "^18.2.0", + "react-dom": "^19.0.0", "react-resizable-panels": "^2.0.20", "react-syntax-highlighter": "^15.5.0" }, diff --git a/packages/markups/package.json b/packages/markups/package.json index ab60f023f7..25b6b57032 100644 --- a/packages/markups/package.json +++ b/packages/markups/package.json @@ -53,8 +53,8 @@ "lint-staged": "^12.4.2", "npm-run-all": "^4.1.5", "prettier": "^2.8.1", - "react": "^17.0.2", - "react-dom": "^17.0.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", "rimraf": "^5.0.1", "rollup": "^2.70.1", "rollup-plugin-analyzer": "^4.0.0", @@ -67,8 +67,8 @@ "typescript": "^5.5.3" }, "peerDependencies": { - "react": ">=17.0.2 <19.0.0", - "react-dom": ">=17.0.2 <19.0.0" + "react": ">=19.0.0 <20.0.0", + "react-dom": ">=19.0.0 <20.0.0" }, "dependencies": { "@embeddedchat/ui-elements": "workspace:^", diff --git a/packages/react/.storybook/main.js b/packages/react/.storybook/main.js index 7c981da605..0db593a3da 100644 --- a/packages/react/.storybook/main.js +++ b/packages/react/.storybook/main.js @@ -5,15 +5,7 @@ const config = { '@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions', - { - name: '@storybook/addon-styling', - options: { - sass: { - // Require your Sass preprocessor here - implementation: require('sass'), - }, - }, - }, + '@storybook/addon-webpack5-compiler-babel', ], framework: { name: '@storybook/react-webpack5', diff --git a/packages/react/package.json b/packages/react/package.json index 82cc802682..9c60c6a4f4 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -13,8 +13,8 @@ ], "scripts": { "prebuild": "rimraf dist", - "build": "rollup -c --context=window --environment NODE_ENV:production", - "watch": "rollup -c --watch --context=window", + "build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 rollup -c --context=window --environment NODE_ENV:production", + "watch": "cross-env NODE_OPTIONS=--max-old-space-size=4096 rollup -c --watch --context=window", "test:lint": "eslint src/**/*.js", "format": "prettier --write 'src/' ", "format:check": "prettier --check 'src/' ", @@ -37,14 +37,14 @@ "@rollup/plugin-commonjs": "^21.0.2", "@rollup/plugin-node-resolve": "^13.1.3", "@rollup/plugin-replace": "^5.0.2", - "@storybook/addon-essentials": "^7.0.26", - "@storybook/addon-interactions": "^7.0.26", - "@storybook/addon-links": "^7.0.26", - "@storybook/addon-styling": "^1.3.6", - "@storybook/blocks": "^7.0.26", - "@storybook/react": "^7.0.26", - "@storybook/react-webpack5": "^7.0.26", - "@storybook/testing-library": "^0.2.0", + "@storybook/addon-essentials": "^8.0.0", + "@storybook/addon-interactions": "^8.0.0", + "@storybook/addon-links": "^8.0.0", + "@storybook/addon-webpack5-compiler-babel": "^3.0.0", + "@storybook/blocks": "^8.0.0", + "@storybook/react": "^8.0.0", + "@storybook/react-webpack5": "^8.0.0", + "@storybook/test": "^8.0.0", "@testing-library/react": "^12.1.4", "babel-jest": "^27.5.1", "concurrently": "^7.2.0", @@ -65,8 +65,8 @@ "lint-staged": "^12.4.2", "npm-run-all": "^4.1.5", "prettier": "^2.8.1", - "react": "^17.0.2", - "react-dom": "^17.0.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", "rimraf": "^5.0.1", "rollup": "^2.70.1", "rollup-plugin-analyzer": "^4.0.0", @@ -76,11 +76,11 @@ "rollup-plugin-terser": "^7.0.2", "sass": "^1.66.1", "schedule": "^0.4.0", - "storybook": "^7.0.26" + "storybook": "^8.0.0" }, "peerDependencies": { - "react": ">=17.0.2 <19.0.0", - "react-dom": ">=17.0.2 <19.0.0" + "react": ">=19.0.0 <20.0.0", + "react-dom": ">=19.0.0 <20.0.0" }, "dependencies": { "@embeddedchat/api": "workspace:^", diff --git a/packages/react/src/hooks/useShowCommands.js b/packages/react/src/hooks/useShowCommands.js index 1379f267c4..05115b6ce4 100644 --- a/packages/react/src/hooks/useShowCommands.js +++ b/packages/react/src/hooks/useShowCommands.js @@ -2,12 +2,11 @@ import { useCallback } from 'react'; const useShowCommands = (commands, setFilteredCommands, setShowCommandList) => useCallback( - async (e) => { + async (cursor, value) => { const getFilteredCommands = (cmd) => commands.filter((c) => c.command.startsWith(cmd.replace('/', ''))); - const cursor = e.target.selectionStart; - const tokens = e.target.value.slice(0, cursor).split(/\s+/); + const tokens = value.slice(0, cursor).split(/\s+/); if (tokens.length === 1 && tokens[0].startsWith('/')) { setFilteredCommands(getFilteredCommands(tokens[0])); diff --git a/packages/react/src/views/AttachmentPreview/PreviewType/audio.js b/packages/react/src/views/AttachmentPreview/PreviewType/audio.js index da4bbe9fff..7dc902b32b 100644 --- a/packages/react/src/views/AttachmentPreview/PreviewType/audio.js +++ b/packages/react/src/views/AttachmentPreview/PreviewType/audio.js @@ -5,7 +5,7 @@ import { Box } from '@embeddedchat/ui-elements'; function PreviewAudio({ previewURL }) { return ( - ); } diff --git a/packages/react/src/views/AttachmentPreview/PreviewType/image.js b/packages/react/src/views/AttachmentPreview/PreviewType/image.js index 5c2d3b4d8a..60573d75af 100644 --- a/packages/react/src/views/AttachmentPreview/PreviewType/image.js +++ b/packages/react/src/views/AttachmentPreview/PreviewType/image.js @@ -7,7 +7,7 @@ function PreviewImage({ previewURL }) { return ( { - RCInstance.auth.onAuthChange((user) => { - if (user) { - RCInstance.addMessageListener(addMessage); - RCInstance.addMessageDeleteListener(removeMessage); - RCInstance.addActionTriggeredListener(onActionTriggerResponse); - RCInstance.addUiInteractionListener(onActionTriggerResponse); - } - }); + if (isUserAuthenticated) { + RCInstance.addMessageListener(addMessage); + RCInstance.addMessageDeleteListener(removeMessage); + RCInstance.addActionTriggeredListener(onActionTriggerResponse); + RCInstance.addUiInteractionListener(onActionTriggerResponse); + } return () => { RCInstance.removeMessageListener(addMessage); @@ -161,28 +159,24 @@ const ChatBody = ({ RCInstance.removeActionTriggeredListener(onActionTriggerResponse); RCInstance.removeUiInteractionListener(onActionTriggerResponse); }; - }, [RCInstance, addMessage, removeMessage, onActionTriggerResponse]); + }, [RCInstance, isUserAuthenticated, addMessage, removeMessage, onActionTriggerResponse]); useEffect(() => { - RCInstance.auth.onAuthChange((user) => { - if (user) { - getMessagesAndRoles(); - setHasMoreMessages(true); - } else { - getMessagesAndRoles(anonymousMode); - } - }); - }, [RCInstance, anonymousMode, getMessagesAndRoles]); + if (isUserAuthenticated) { + getMessagesAndRoles(); + setHasMoreMessages(true); + } else { + getMessagesAndRoles(anonymousMode); + } + }, [RCInstance, isUserAuthenticated, anonymousMode, getMessagesAndRoles]); useEffect(() => { - RCInstance.auth.onAuthChange((user) => { - if (user) { - fetchAndSetPermissions(); - } else { - permissionsRef.current = null; - } - }); - }, []); + if (isUserAuthenticated) { + fetchAndSetPermissions(); + } else { + permissionsRef.current = null; + } + }, [isUserAuthenticated, fetchAndSetPermissions, permissionsRef]); // Expose clearUnreadDivider function via ref for ChatInput to call useEffect(() => { diff --git a/packages/react/src/views/ChatHeader/ChatHeader.js b/packages/react/src/views/ChatHeader/ChatHeader.js index a310e538fd..70a594a2b8 100644 --- a/packages/react/src/views/ChatHeader/ChatHeader.js +++ b/packages/react/src/views/ChatHeader/ChatHeader.js @@ -436,7 +436,7 @@ const ChatHeader = ({ {avatarUrl && ( - avatar + avatar )} {surfaceOptions.length > 0 && ( diff --git a/packages/react/src/views/ChatInput/ChatInput.js b/packages/react/src/views/ChatInput/ChatInput.js index f6fb0e111f..009e101433 100644 --- a/packages/react/src/views/ChatInput/ChatInput.js +++ b/packages/react/src/views/ChatInput/ChatInput.js @@ -107,6 +107,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { isRecordingMessage, upsertMessage, replaceMessage, + removeMessage, clearQuoteMessages, threadId, deletedMessage, @@ -119,6 +120,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { replaceMessage: state.replaceMessage, threadId: state.threadMainMessage?._id, clearQuoteMessages: state.clearQuoteMessages, + removeMessage: state.removeMessage, deletedMessage: state.deletedMessage, })); @@ -161,20 +163,17 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { ); useEffect(() => { - RCInstance.auth.onAuthChange((user) => { - if (user) { - RCInstance.getCommandsList() - .then((response) => setCommands(response.commands || [])) - .catch(console.error); - - RCInstance.getChannelMembers(isChannelPrivate) - .then((channelMembers) => - setMembersHandler(channelMembers.members || []) - ) - .catch(console.error); - } - }); - }, [RCInstance, isChannelPrivate, setMembersHandler]); + if (!isUserAuthenticated) return; + RCInstance.getCommandsList() + .then((response) => setCommands(response.commands || [])) + .catch(console.error); + + RCInstance.getChannelMembers(isChannelPrivate) + .then((channelMembers) => + setMembersHandler(channelMembers.members || []) + ) + .catch(console.error); + }, [RCInstance, isUserAuthenticated, isChannelPrivate, setMembersHandler]); useEffect(() => { if (editMessage.attachments) { @@ -275,34 +274,26 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { } }; - const sendTypingStart = async () => { - try { - if (typingRef.current && messageRef.current.value?.length) { - return; - } - if (messageRef.current.value?.length) { - typingRef.current = true; - timerRef.current = setTimeout(() => { - typingRef.current = false; - }, [15000]); - await RCInstance.sendTypingStatus(username, true); - } else { - clearTimeout(timerRef.current); + const sendTypingStart = () => { + if (typingRef.current && messageRef.current.value?.length) { + return; + } + if (messageRef.current.value?.length) { + typingRef.current = true; + timerRef.current = setTimeout(() => { typingRef.current = false; - await RCInstance.sendTypingStatus(username, false); - } - } catch (e) { - console.error(e); + }, [15000]); + RCInstance.sendTypingStatus(username, true).catch(() => {}); + } else { + clearTimeout(timerRef.current); + typingRef.current = false; + RCInstance.sendTypingStatus(username, false).catch(() => {}); } }; - const sendTypingStop = async () => { - try { - typingRef.current = false; - await RCInstance.sendTypingStatus(username, false); - } catch (e) { - console.error(e); - } + const sendTypingStop = () => { + typingRef.current = false; + RCInstance.sendTypingStatus(username, false).catch(() => {}); }; const handleSendNewMessage = async (message) => { @@ -354,9 +345,12 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { ECOptions.enableThreads ? threadId : undefined ); - if (res.success) { + if (res?.success) { clearQuoteMessages(); - replaceMessage(pendingMessage, res.message); + replaceMessage(pendingMessage._id, res.message); + } else { + // If REST send failed, remove the pending message so it doesn't stay grey + removeMessage(pendingMessage._id); } }; @@ -448,7 +442,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { if (e !== null) { handleNewLine(e, false); searchMentionUser(message); - showCommands(e); + showCommands(e.target.selectionStart, e.target.value); searchEmoji(message); } }; @@ -724,7 +718,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { /> ) : null ) : ( - )} diff --git a/packages/react/src/views/EmbeddedChat.js b/packages/react/src/views/EmbeddedChat.js index f3b94c7b48..b2dc687247 100644 --- a/packages/react/src/views/EmbeddedChat.js +++ b/packages/react/src/views/EmbeddedChat.js @@ -134,7 +134,7 @@ const EmbeddedChat = (props) => { }, [RCInstance, auth, setIsLoginIn]); useEffect(() => { - RCInstance.auth.onAuthChange((user) => { + const handleAuthChange = (user) => { if (user) { RCInstance.connect() .then(() => { @@ -149,9 +149,16 @@ const EmbeddedChat = (props) => { }) .catch(console.error); } else { + // Close the DDP connection on logout so the next login gets a fresh connection. + RCInstance.close().catch(console.error); setIsUserAuthenticated(false); } - }); + }; + RCInstance.auth.onAuthChange(handleAuthChange); + + return () => { + RCInstance.auth.removeAuthListener(handleAuthChange); + }; }, [ RCInstance, setAuthenticatedName, diff --git a/packages/react/src/views/LoginForm/LoginForm.js b/packages/react/src/views/LoginForm/LoginForm.js index 1285e5e1eb..1b56fddb3a 100644 --- a/packages/react/src/views/LoginForm/LoginForm.js +++ b/packages/react/src/views/LoginForm/LoginForm.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState, useRef } from 'react'; import { css } from '@emotion/react'; import { GenericModal, @@ -13,8 +13,8 @@ import { useRCAuth } from '../../hooks/useRCAuth'; import styles from './LoginForm.styles'; export default function LoginForm() { - const [userOrEmail, setUserOrEmail] = useState(null); - const [password, setPassword] = useState(null); + const userRef = useRef(null); + const passRef = useRef(null); const [showPassword, setShowPassword] = useState(false); const [usernameError, setUsernameError] = useState(false); const [passwordError, setPasswordError] = useState(false); @@ -26,36 +26,40 @@ export default function LoginForm() { const { theme } = useTheme(); - useEffect(() => { - if (userOrEmail !== null && userOrEmail.trim() === '') { + const handleSubmit = (e) => { + if (e && e.preventDefault) e.preventDefault(); + const u = userRef.current?.value || ''; + const p = passRef.current?.value || ''; + + let hasError = false; + if (u.trim() === '') { setUsernameError(true); + hasError = true; } else { setUsernameError(false); } - if (password !== null && password.trim() === '') { + if (p.trim() === '') { setPasswordError(true); + hasError = true; } else { setPasswordError(false); } - }, [userOrEmail, password]); - const handleSubmit = () => { - if (!userOrEmail) setUserOrEmail(''); - if (!password) setPassword(''); - handleLogin(userOrEmail, password); + if (!hasError) { + handleLogin(u, p); + } }; + const handleClose = () => { - setUserOrEmail(null); - setPassword(null); setIsLoginModalOpen(false); }; - const handleEdituserOrEmail = (e) => { - setUserOrEmail(e.target.value); + const handleEdituserOrEmail = () => { + if (usernameError) setUsernameError(false); }; - const handleEditPassword = (e) => { - setPassword(e.target.value); + const handleEditPassword = () => { + if (passwordError) setPasswordError(false); }; const handleTogglePassword = () => { setShowPassword(!showPassword); @@ -69,15 +73,18 @@ export default function LoginForm() { const fields = [ { label: 'Email or username', + ref: userRef, onChange: handleEdituserOrEmail, placeholder: 'example@example.com', error: usernameError, }, { label: 'Password', + ref: passRef, type: showPassword ? 'text' : 'password', onChange: handleEditPassword, error: passwordError, + autoComplete: 'new-password', }, ]; @@ -96,6 +103,7 @@ export default function LoginForm() { {message.file.name} @@ -320,7 +320,7 @@ export const MessageToolbox = ({ style={{ maxWidth: '100%', maxHeight: '200px' }} > Your browser does not support the video tag. @@ -328,7 +328,7 @@ export const MessageToolbox = ({ ) : message.file.type.startsWith('audio/') ? (