diff --git a/README.md b/README.md index 2c68bde9d8..4d24fe4992 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,8 @@ This environment offers a complete setup for developing and testing the `Embedde - Theming Technical: [Technical Overview](https://rocketchat.github.io/EmbeddedChat/docs/docs/Development/theming_technical) – Technical aspects of theming. + - Matrix Integration: [Guide](https://rocketchat.github.io/EmbeddedChat/docs/docs/Usage/matrix) – Configure Matrix Federation in Embedded Chat. + ### Contributors diff --git a/packages/api/src/EmbeddedChatApi.ts b/packages/api/src/EmbeddedChatApi.ts index b775016170..6f2408414e 100644 --- a/packages/api/src/EmbeddedChatApi.ts +++ b/packages/api/src/EmbeddedChatApi.ts @@ -238,12 +238,12 @@ export default class EmbeddedChatApi { * All subscriptions are implemented here. * TODO: Add logic to call thread message event listeners. To be done after thread implementation */ - async connect() { + async connect(isChannelPrivate = false) { // Guard against concurrent connect() calls (e.g. React StrictMode double-invoke) if (this._connectPromise) { return this._connectPromise; } - this._connectPromise = this._doConnect().finally(() => { + this._connectPromise = this._doConnect(isChannelPrivate).finally(() => { this._connectPromise = null; }); return this._connectPromise; @@ -261,7 +261,18 @@ export default class EmbeddedChatApi { return message; } - private async _doConnect() { + async joinRoom(isChannelPrivate = false) { + try { + const roomType = isChannelPrivate ? "groups" : "channels"; + await this._restRequest(`/v1/${roomType}.join`, "POST", { + roomId: this.rid, + }); + } catch (err) { + console.debug("[EmbeddedChat] joinRoom skipped:", err); + } + } + + private async _doConnect(isChannelPrivate = false) { try { this.close(); // before connection, all previous subscriptions should be cancelled await this.sdk.connection.connect(); @@ -269,7 +280,9 @@ export default class EmbeddedChatApi { const currentUser = (await this.auth.getCurrentUser()) as any; const token = currentUser?.authToken || currentUser?.data?.authToken; if (token) { + this._applyCredentials(currentUser); await this.sdk.account.loginWithToken(token); + await this.joinRoom(isChannelPrivate); } // Subscribe to room messages diff --git a/packages/docs/docs/Usage/matrix.md b/packages/docs/docs/Usage/matrix.md new file mode 100644 index 0000000000..850dbec700 --- /dev/null +++ b/packages/docs/docs/Usage/matrix.md @@ -0,0 +1,73 @@ +--- +title: Matrix Integration +--- + +# Matrix Federation Integration + +EmbeddedChat features native support for **Matrix Federation**, enabling seamless real-time communication with users on external Matrix servers (such as `matrix.org`). + +Through this integration, your EmbeddedChat instance behaves like a modern Matrix client: displaying Matrix-styled user interfaces, handling federated message styling, and routing room join handshakes through Rocket.Chat's native Matrix gateway. + +![EmbeddedChat Matrix Demo](https://github.com/user-attachments/assets/d1ec8072-4618-4af6-b13f-ef3046502dd1) + +--- + +## Key Features + +- **Matrix Theme (`theme="matrix"`)**: An Element-inspired styling scheme featuring emerald green brand colors (`#0dbd8b`) and a sleek charcoal dark mode (`#15191e`). +- **Timeline Layout Mode (`layoutMode="timeline"`)**: A flat, bubble-less, consecutive message layout reminiscent of classic chat and modern Matrix/Element clients. +- **Federated Status Badge**: A header indicator detailing the federated status of the channel and its origin homeserver. +- **Server Origin Badges**: Visual badges on message headers identifying external homeservers (e.g., `matrix.org`, `envs.net`) for federated users. +- **Proxy-based Federation Join**: Intelligent routing for room-join requests, automatically detecting federated room metadata and invoking the Matrix federation join API endpoint. + +--- + +## Configuration & Usage + +To configure the Matrix visual style and enable federation styling, pass the following props to the `EmbeddedChat` component: + +```jsx +import { EmbeddedChat } from '@embeddedchat/react'; + +function App() { + return ( + + ); +} +``` + +### Prop Details + +| Prop | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `theme` | `string` | `null` | Pass `"matrix"` to load the Element-inspired theme. | +| `dark` | `boolean` | `false` | Enable dark mode. When paired with `theme="matrix"`, this renders the charcoal dark aesthetic (`#15191e`). | +| `layoutMode` | `"default" \| "timeline"` | `"default"` | Set to `"timeline"` to enable the flat, compact message layout with a left hover border accent. | + +--- + +## User Experience & Customization + +### 1. Matrix Theme Customization +When integrating the widget, using `theme="matrix"` automatically applies the HSL palette inspired by Element Web: +- **Brand Accent (Emerald Green)**: `#0dbd8b` (`hsl(163, 88%, 41%)`) +- **Dark Mode Background (Charcoal Slate)**: `#15191e` (`hsl(216, 18%, 10%)`) +- **Light Mode Background (Pure White)**: `#ffffff` +- **Typography**: Clean, readable sans-serif layout using `"Inter", "Helvetica Neue", sans-serif` for all UI components. + +### 2. Timeline Layout Mode +Configuring `layoutMode="timeline"` replaces standard message bubbles with a flat, continuous design: +- Message bubbles are removed, allowing messages to span the full width of the container. +- Consecutive messages from the same sender are clustered together under a single name/timestamp header with reduced vertical padding. +- Active messages are highlighted on hover with a theme-colored left border. + +### 3. Federated Room & User Badges +To help users identify federated communication: +- **Federated Room Indicator**: A globe icon is displayed in the widget header in place of the standard `#` or private lock icon to show the room is federated. +- **Server Origin Badges**: External federated users (usernames containing colons, e.g. `@user:matrix.org`) automatically get an emerald-bordered badge next to their username showing their remote domain (e.g. `matrix.org`). diff --git a/packages/docs/sidebars.js b/packages/docs/sidebars.js index 1451557e64..db32901b11 100644 --- a/packages/docs/sidebars.js +++ b/packages/docs/sidebars.js @@ -38,6 +38,7 @@ const sidebars = { 'Usage/theming', 'Usage/authentication', 'Usage/ec_rc_setup', + 'Usage/matrix', ], }, ], diff --git a/packages/react/src/stories/EmbeddedChatWithMatrix.stories.js b/packages/react/src/stories/EmbeddedChatWithMatrix.stories.js new file mode 100644 index 0000000000..c90c4d15d3 --- /dev/null +++ b/packages/react/src/stories/EmbeddedChatWithMatrix.stories.js @@ -0,0 +1,25 @@ +import { EmbeddedChat } from '..'; + +export default { + title: 'EmbeddedChat/WithMatrix', + component: EmbeddedChat, +}; + +export const WithMatrix = { + args: { + host: process.env.STORYBOOK_RC_HOST || 'http://localhost:3000', + roomId: process.env.RC_ROOM_ID || 'GENERAL', + channelName: 'general', + anonymousMode: false, + toastBarPosition: 'bottom right', + showRoles: true, + enableThreads: true, + hideHeader: false, + auth: { + flow: 'PASSWORD', + }, + theme: 'matrix', + layoutMode: 'timeline', + dark: true, + }, +}; diff --git a/packages/react/src/theme/MatrixTheme.js b/packages/react/src/theme/MatrixTheme.js new file mode 100644 index 0000000000..b41647f51e --- /dev/null +++ b/packages/react/src/theme/MatrixTheme.js @@ -0,0 +1,104 @@ +const MatrixTheme = { + radius: '0.2rem', + commonColors: { + black: 'hsl(0, 100%, 0%)', + white: 'hsl(0, 100%, 100%)', + }, + schemes: { + light: { + background: 'hsl(0, 0%, 100%)', + foreground: 'hsl(216, 18%, 10%)', + card: 'hsl(0, 0%, 100%)', + cardForeground: 'hsl(216, 18%, 10%)', + popover: 'hsl(0, 0%, 100%)', + popoverForeground: 'hsl(216, 18%, 10%)', + primary: 'hsl(163, 88%, 41%)', + primaryForeground: 'hsl(0, 0%, 100%)', + secondary: 'hsl(163, 40%, 96%)', + secondaryForeground: 'hsl(163, 88%, 20%)', + muted: 'hsl(163, 20%, 96%)', + mutedForeground: 'hsl(216, 10%, 45%)', + accent: 'hsl(163, 40%, 94%)', + accentForeground: 'hsl(163, 88%, 25%)', + destructive: 'hsl(0, 84.2%, 60.2%)', + destructiveForeground: 'hsl(0, 0%, 98%)', + border: 'hsl(163, 20%, 90%)', + input: 'hsl(163, 20%, 90%)', + ring: 'hsl(163, 88%, 41%)', + warning: 'hsl(38, 92%, 50%)', + warningForeground: 'hsl(48, 96%, 89%)', + success: 'hsl(163, 88%, 41%)', + successForeground: 'hsl(0, 0%, 100%)', + info: 'hsl(214, 76.4%, 50.2%)', + infoForeground: 'hsl(214.3, 77.8%, 92.9%)', + }, + dark: { + background: 'hsl(216, 18%, 10%)', + foreground: 'hsl(210, 40%, 98%)', + card: 'hsl(216, 18%, 12%)', + cardForeground: 'hsl(210, 40%, 98%)', + popover: 'hsl(216, 18%, 10%)', + popoverForeground: 'hsl(210, 40%, 98%)', + primary: 'hsl(163, 88%, 41%)', + primaryForeground: 'hsl(0, 0%, 100%)', + secondary: 'hsl(216, 15%, 15%)', + secondaryForeground: 'hsl(210, 40%, 98%)', + muted: 'hsl(216, 15%, 15%)', + mutedForeground: 'hsl(216, 10%, 65%)', + accent: 'hsl(163, 30%, 15%)', + accentForeground: 'hsl(163, 88%, 41%)', + destructive: 'hsl(0, 62.8%, 30.6%)', + destructiveForeground: 'hsl(0, 0%, 98%)', + border: 'hsl(216, 15%, 20%)', + input: 'hsl(216, 15%, 20%)', + ring: 'hsl(163, 88%, 41%)', + warning: 'hsl(48, 96%, 89%)', + warningForeground: 'hsl(38, 92%, 50%)', + success: 'hsl(163, 88%, 41%)', + successForeground: 'hsl(0, 0%, 100%)', + info: 'hsl(214.3, 77.8%, 92.9%)', + infoForeground: 'hsl(214.4, 75.8%, 19.4%)', + }, + }, + typography: { + default: { + fontFamily: '"Inter", "Helvetica Neue", sans-serif', + fontSize: 14, + fontWeightLight: 300, + fontWeightRegular: 400, + fontWeightMedium: 500, + fontWeightBold: 700, + }, + h1: { + fontSize: '2.625rem', + fontWeight: 800, + }, + h2: { + fontSize: '1.875rem', + fontWeight: 800, + }, + h3: { + fontSize: '1.5rem', + fontWeight: 400, + }, + h4: { + fontSize: '1.25rem', + fontWeight: 400, + }, + h5: { + fontSize: '1rem', + fontWeight: 400, + }, + h6: { + fontSize: '0.875rem', + fontWeight: 500, + }, + }, + shadows: [ + 'none', + 'rgba(17, 17, 26, 0.05) 0px 1px 0px, rgba(17, 17, 26, 0.1) 0px 0px 8px', + 'rgba(100, 100, 111, 0.2) 0px 7px 29px 0px', + ], +}; + +export default MatrixTheme; diff --git a/packages/react/src/views/ChatHeader/ChatHeader.js b/packages/react/src/views/ChatHeader/ChatHeader.js index c28e9ad39e..781d4f3361 100644 --- a/packages/react/src/views/ChatHeader/ChatHeader.js +++ b/packages/react/src/views/ChatHeader/ChatHeader.js @@ -32,6 +32,24 @@ import getChatHeaderStyles from './ChatHeader.styles'; import useSetExclusiveState from '../../hooks/useSetExclusiveState'; import SurfaceMenu from '../SurfaceMenu/SurfaceMenu'; +const GlobeIcon = ({ size }) => ( + + + + + +); + const ChatHeader = ({ isClosable, setClosableState, @@ -84,7 +102,14 @@ const ChatHeader = ({ const workspaceLevelRoles = useUserStore((state) => state.roles); const { RCInstance, ECOptions } = useRCContext(); - const { channelName, anonymousMode, showRoles } = ECOptions ?? {}; + const { + channelName, + anonymousMode, + showRoles, + theme: configTheme, + } = ECOptions ?? {}; + + const isFederated = channelInfo?.federated || configTheme === 'matrix'; const isUserAuthenticated = useUserStore( (state) => state.isUserAuthenticated @@ -203,8 +228,7 @@ const ChatHeader = ({ ) { setIsChannelArchived(true); const roomInfo = await RCInstance.getRoomInfo(); - const roomData = roomInfo.result[roomInfo.result.length - 1]; - setChannelInfo(roomData); + setChannelInfo(roomInfo?.room); } else if ('errorType' in res && res.errorType === 'Not Allowed') { dispatchToastMessage({ type: 'error', @@ -391,16 +415,20 @@ const ChatHeader = ({ css={styles.channelName} onClick={() => setExclusiveState(setShowChannelinfo)} > - + {isFederated ? ( + + ) : ( + + )}
{ secure = false, dark = false, remoteOpt = false, + layoutMode = 'bubble', } = config; const auth = useMemo( @@ -172,7 +179,8 @@ const EmbeddedChat = (props) => { useEffect(() => { const handleAuthChange = (user) => { if (user) { - RCInstance.connect() + const { isChannelPrivate } = useChannelStore.getState(); + RCInstance.connect(isChannelPrivate) .then(() => { console.log(`Connected to RocketChat ${RCInstance.host}`); const me = user.me || user.data?.me; @@ -243,6 +251,7 @@ const EmbeddedChat = (props) => { showUsername, hideHeader, anonymousMode, + layoutMode, }), [ enableThreads, @@ -259,6 +268,7 @@ const EmbeddedChat = (props) => { showUsername, hideHeader, anonymousMode, + layoutMode, ] ); @@ -267,14 +277,21 @@ const EmbeddedChat = (props) => { [RCInstance, ECOptions] ); + const resolvedTheme = useMemo(() => { + if (theme === 'matrix') { + return MatrixTheme; + } + return theme || DefaultTheme; + }, [theme]); + if (!isSynced) return null; return ( - + { const isStarred = @@ -234,7 +237,9 @@ const Message = ({ { +export const getMessageStyles = ({ theme, mode }) => { const styles = { main: css` display: flex; @@ -15,6 +15,26 @@ export const getMessageStyles = ({ theme }) => { padding-left: 0.8rem; } `, + timelineMain: css` + display: flex; + flex-direction: row; + align-items: flex-start; + padding-top: 0.25rem; + padding-bottom: 0.25rem; + padding-left: 2.25rem; + padding-right: 2.25rem; + color: ${theme.colors.foreground}; + border-left: 3px solid transparent; + &:hover { + border-left: 3px solid ${theme.colors.primary}; + background-color: ${mode === 'light' + ? 'rgba(13, 189, 139, 0.05)' + : 'rgba(13, 189, 139, 0.1)'}; + } + @media (max-width: 768px) { + padding-left: 0.8rem; + } + `, messageEditing: css` background-color: ${theme.colors.secondary}; &:hover { diff --git a/packages/react/src/views/Message/MessageHeader.js b/packages/react/src/views/Message/MessageHeader.js index a4bff59b10..1e135c5308 100644 --- a/packages/react/src/views/Message/MessageHeader.js +++ b/packages/react/src/views/Message/MessageHeader.js @@ -1,4 +1,5 @@ import React from 'react'; +import { css } from '@emotion/react'; import PropTypes from 'prop-types'; import { format } from 'date-fns'; import { @@ -27,6 +28,9 @@ const MessageHeader = ({ const { theme } = useTheme(); const styles = getMessageHeaderStyles(theme); const getDisplayNameColor = useDisplayNameColor(); + const username = message.u?.username || ''; + const isFederatedUser = username.includes(':'); + const serverDomain = isFederatedUser ? username.split(':')[1] : null; const authenticatedUserId = useUserStore((state) => state.userId); const showUsername = ECOptions?.showUsername; @@ -135,6 +139,25 @@ const MessageHeader = ({ @{message.u.username} )} + {serverDomain && ( + + {serverDomain} + + )} {!message.t && ECOptions?.showRoles && isRoles && ( <> {admins?.includes(message?.u?.username) && (