Skip to content

Commit ec37e1d

Browse files
committed
feat: add Matrix federation support — federated room detection, Matrix user badges, initials avatars, and Storybook demo
1 parent daf291d commit ec37e1d

8 files changed

Lines changed: 302 additions & 16 deletions

File tree

packages/api/src/EmbeddedChatApi.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,29 @@ export default class EmbeddedChatApi {
491491
}
492492
}
493493

494+
/**
495+
* Returns federation metadata for the current room.
496+
* @returns {{ isFederated: boolean, homeserver: string | null }}
497+
*/
498+
async isFederatedRoom(): Promise<{ isFederated: boolean; homeserver: string | null }> {
499+
try {
500+
const info = await this.channelInfo();
501+
const room = info?.room;
502+
if (!room) return { isFederated: false, homeserver: null };
503+
504+
const federated =
505+
room.federated === true ||
506+
room.federation != null;
507+
508+
const homeserver: string | null =
509+
room.federation?.origin ?? null;
510+
511+
return { isFederated: federated, homeserver };
512+
} catch {
513+
return { isFederated: false, homeserver: null };
514+
}
515+
}
516+
494517
async getRoomInfo() {
495518
try {
496519
const { userId, authToken } = (await this.auth.getCurrentUser()) || {};
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import React, { createContext, useContext, useEffect, useState } from 'react';
2+
3+
/**
4+
* Exposes federation state to the component tree.
5+
* @property {boolean} isFederated - true when the active room is Matrix-bridged
6+
* @property {string|null} matrixHomeserver - the homeserver origin (e.g. "matrix.org")
7+
* @property {boolean} federationLoading - true while room info is being fetched
8+
*/
9+
const FederationContext = createContext({
10+
isFederated: false,
11+
matrixHomeserver: null,
12+
federationLoading: false,
13+
});
14+
15+
export const FederationProvider = ({ children, RCInstance }) => {
16+
const [isFederated, setIsFederated] = useState(false);
17+
const [matrixHomeserver, setMatrixHomeserver] = useState(null);
18+
const [federationLoading, setFederationLoading] = useState(true);
19+
20+
useEffect(() => {
21+
if (!RCInstance) {
22+
setFederationLoading(false);
23+
return;
24+
}
25+
26+
let cancelled = false;
27+
28+
const detectFederation = async () => {
29+
try {
30+
setFederationLoading(true);
31+
const info = await RCInstance.channelInfo();
32+
if (cancelled) return;
33+
34+
const room = info?.room;
35+
const federated =
36+
room?.federated === true || room?.federation != null;
37+
38+
setIsFederated(federated);
39+
40+
if (federated && room?.federation?.origin) {
41+
setMatrixHomeserver(room.federation.origin);
42+
}
43+
} catch {
44+
if (!cancelled) setIsFederated(false);
45+
} finally {
46+
if (!cancelled) setFederationLoading(false);
47+
}
48+
};
49+
50+
detectFederation();
51+
return () => {
52+
cancelled = true;
53+
};
54+
}, [RCInstance]);
55+
56+
return (
57+
<FederationContext.Provider
58+
value={{ isFederated, matrixHomeserver, federationLoading }}
59+
>
60+
{children}
61+
</FederationContext.Provider>
62+
);
63+
};
64+
65+
export const useFederation = () => useContext(FederationContext);
66+
67+
export default FederationContext;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* Returns true if the username looks like a Matrix user ID (@localpart:homeserver.tld).
3+
* @param {string} username
4+
* @returns {boolean}
5+
*/
6+
export const isMatrixUser = (username) => {
7+
if (!username) return false;
8+
return /^@[^:]+:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(username);
9+
};
10+
11+
/**
12+
* Parses a Matrix user ID into localpart and homeserver.
13+
* @param {string} matrixId e.g. "@alice:matrix.org"
14+
* @returns {{ localpart: string, homeserver: string } | null}
15+
*/
16+
export const parseMatrixUserId = (matrixId) => {
17+
if (!isMatrixUser(matrixId)) return null;
18+
const withoutAt = matrixId.slice(1);
19+
const colonIdx = withoutAt.indexOf(':');
20+
return {
21+
localpart: withoutAt.slice(0, colonIdx),
22+
homeserver: withoutAt.slice(colonIdx + 1),
23+
};
24+
};
25+
26+
/**
27+
* Returns a display-friendly label, truncating long homeservers.
28+
* @param {string} matrixId
29+
* @returns {string}
30+
*/
31+
export const getMatrixDisplayLabel = (matrixId) => {
32+
const parsed = parseMatrixUserId(matrixId);
33+
if (!parsed) return matrixId;
34+
const { localpart, homeserver } = parsed;
35+
const shortHost =
36+
homeserver.length > 20 ? homeserver.slice(0, 18) + '…' : homeserver;
37+
return `@${localpart}:${shortHost}`;
38+
};
39+
40+
/**
41+
* Returns 1-2 char initials for avatar fallback.
42+
* @param {string} matrixId
43+
* @returns {string}
44+
*/
45+
export const getMatrixInitials = (matrixId) => {
46+
const parsed = parseMatrixUserId(matrixId);
47+
if (!parsed) return '?';
48+
return parsed.localpart.slice(0, 2).toUpperCase();
49+
};
50+
51+
/**
52+
* Generates a deterministic background color from a string.
53+
* @param {string} str
54+
* @returns {string}
55+
*/
56+
export const getAvatarColor = (str) => {
57+
const COLORS = [
58+
'#e57373', '#f06292', '#ba68c8', '#9575cd',
59+
'#7986cb', '#64b5f6', '#4fc3f7', '#4dd0e1',
60+
'#4db6ac', '#81c784', '#aed581', '#ffd54f',
61+
'#ffb74d', '#ff8a65', '#a1887f', '#90a4ae',
62+
];
63+
let hash = 0;
64+
for (let i = 0; i < str.length; i++) {
65+
hash = str.charCodeAt(i) + ((hash << 5) - hash);
66+
}
67+
return COLORS[Math.abs(hash) % COLORS.length];
68+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { EmbeddedChat } from '..';
2+
3+
export default {
4+
title: 'EmbeddedChat/Federated (Matrix)',
5+
component: EmbeddedChat,
6+
};
7+
8+
export const FederatedRoom = {
9+
args: {
10+
host: process.env.STORYBOOK_RC_HOST || 'http://localhost:3000',
11+
roomId: process.env.STORYBOOK_FEDERATED_ROOM_ID || 'GENERAL',
12+
channelName: 'federated-room',
13+
anonymousMode: false,
14+
toastBarPosition: 'bottom right',
15+
showRoles: true,
16+
showUsername: true,
17+
enableThreads: true,
18+
hideHeader: false,
19+
auth: {
20+
flow: 'PASSWORD',
21+
},
22+
dark: false,
23+
federation: true,
24+
},
25+
};
26+
27+
export const FederatedRoomDark = {
28+
args: {
29+
...FederatedRoom.args,
30+
dark: true,
31+
channelName: 'federated-room (dark)',
32+
},
33+
};

packages/react/src/views/EmbeddedChat.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import { ChatLayout } from './ChatLayout';
1919
import { ChatHeader } from './ChatHeader';
2020
import { RCInstanceProvider } from '../context/RCInstance';
21+
import { FederationProvider } from '../context/FederationContext';
2122
import { useUserStore, useLoginStore, useMessageStore } from '../store';
2223
import DefaultTheme from '../theme/DefaultTheme';
2324
import { getTokenStorage } from '../lib/auth';
@@ -58,6 +59,8 @@ const EmbeddedChat = (props) => {
5859
secure = false,
5960
dark = false,
6061
remoteOpt = false,
62+
/** Enable Matrix federation support — detects federated rooms and renders Matrix user identities correctly */
63+
federation = false,
6164
} = config;
6265

6366
const hasMounted = useRef(false);
@@ -227,6 +230,7 @@ const EmbeddedChat = (props) => {
227230
return (
228231
<ThemeProvider theme={theme || DefaultTheme} mode={dark ? 'dark' : 'light'}>
229232
<RCInstanceProvider value={RCContextValue}>
233+
<FederationProvider RCInstance={federation ? RCInstance : null}>
230234
<Box
231235
css={[
232236
styles.embeddedchat(theme || DefaultTheme, dark),
@@ -256,6 +260,7 @@ const EmbeddedChat = (props) => {
256260
<div id="overlay-items" />
257261
</ToastBarProvider>
258262
</Box>
263+
</FederationProvider>
259264
</RCInstanceProvider>
260265
</ThemeProvider>
261266
);
@@ -288,6 +293,7 @@ EmbeddedChat.propTypes = {
288293
style: PropTypes.object,
289294
hideHeader: PropTypes.bool,
290295
dark: PropTypes.bool,
296+
federation: PropTypes.bool,
291297
};
292298

293299
export default memo(EmbeddedChat);
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import React from 'react';
2+
import { getMatrixInitials, getAvatarColor } from '../../lib/federationUtils';
3+
4+
/**
5+
* Coloured initials avatar for Matrix-federated users.
6+
* Used as a fallback when the user has no Rocket.Chat avatar.
7+
*/
8+
const MatrixAvatar = ({ matrixId, size = '2.25em', onClick }) => {
9+
const initials = getMatrixInitials(matrixId);
10+
const bg = getAvatarColor(matrixId);
11+
12+
const style = {
13+
display: 'inline-flex',
14+
alignItems: 'center',
15+
justifyContent: 'center',
16+
width: size,
17+
height: size,
18+
borderRadius: '50%',
19+
backgroundColor: bg,
20+
color: '#fff',
21+
fontSize: `calc(${size} * 0.4)`,
22+
fontWeight: 700,
23+
flexShrink: 0,
24+
cursor: onClick ? 'pointer' : 'default',
25+
userSelect: 'none',
26+
};
27+
28+
return (
29+
<span
30+
style={style}
31+
onClick={onClick}
32+
aria-label={`Avatar for ${matrixId}`}
33+
role={onClick ? 'button' : 'img'}
34+
tabIndex={onClick ? 0 : undefined}
35+
onKeyDown={onClick ? (e) => e.key === 'Enter' && onClick() : undefined}
36+
>
37+
{initials}
38+
</span>
39+
);
40+
};
41+
42+
export default MatrixAvatar;

packages/react/src/views/Message/MessageAvatarContainer.js

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import RCContext from '../../context/RCInstance';
1010
import { getMessageAvatarContainerStyles } from './Message.styles';
1111
import useSetExclusiveState from '../../hooks/useSetExclusiveState';
1212
import { useUserStore } from '../../store';
13+
import { useFederation } from '../../context/FederationContext';
14+
import { isMatrixUser } from '../../lib/federationUtils';
15+
import MatrixAvatar from './MatrixAvatar';
1316

1417
const MessageAvatarContainer = ({
1518
message,
@@ -20,6 +23,8 @@ const MessageAvatarContainer = ({
2023
const { RCInstance } = useContext(RCContext);
2124
const { theme } = useTheme();
2225
const styles = getMessageAvatarContainerStyles(theme);
26+
const { isFederated } = useFederation();
27+
2328
const getUserAvatarUrl = (username) => {
2429
const host = RCInstance.getHost();
2530
const URL = `${host}/avatar/${username}`;
@@ -40,20 +45,32 @@ const MessageAvatarContainer = ({
4045
return (
4146
<Box css={styles.container}>
4247
{!sequential ? (
43-
<Avatar
44-
url={getUserAvatarUrl(message.u.username)}
45-
alt="avatar"
46-
size={
47-
window.matchMedia('(max-width: 768px)').matches
48-
? message.t
49-
? '1.2em'
50-
: '1.5em'
51-
: message.t
52-
? '1.5em'
53-
: '2.25em'
54-
}
55-
onClick={handleAvatarClick}
56-
/>
48+
isFederated && isMatrixUser(message.u.username) ? (
49+
<MatrixAvatar
50+
matrixId={message.u.username}
51+
size={
52+
window.matchMedia('(max-width: 768px)').matches
53+
? message.t ? '1.2em' : '1.5em'
54+
: message.t ? '1.5em' : '2.25em'
55+
}
56+
onClick={handleAvatarClick}
57+
/>
58+
) : (
59+
<Avatar
60+
url={getUserAvatarUrl(message.u.username)}
61+
alt="avatar"
62+
size={
63+
window.matchMedia('(max-width: 768px)').matches
64+
? message.t
65+
? '1.2em'
66+
: '1.5em'
67+
: message.t
68+
? '1.5em'
69+
: '2.25em'
70+
}
71+
onClick={handleAvatarClick}
72+
/>
73+
)
5774
) : null}
5875
{isStarred && sequential ? (
5976
<Tooltip text="Starred" position="top">

0 commit comments

Comments
 (0)