Skip to content

About

NPM package "Ethora Chat Component". React.js. Quickly deploy a chat interface embedded into your web portal, as a website pop-up widget or a stand-alone app. Available via npm registry: https://www.npmjs.com/package/@ethora/chat-component

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

11 stars

Watchers

3 watching

Forks

Latest commit

 

History

1,309 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ethora Chat Component (@ethora/chat-component)

GitHub watchers GitHub forks GitHub Repo stars GitHub repo size GitHub language count GitHub top language GitHub commit activity (branch) GitHub issues GitHub closed issues GitHub GitHub contributors

JavaScript React TypeScript JWT

Discord Twitter URL Website YouTube Channel Subscribers

React + TypeScript chat UI component powered by Ethora backend APIs and XMPP.
Use it as a standalone chat page, as an embedded widget in your existing app, or as a customizable chat foundation with your own auth and UI.

Table of Contents

Overview

@ethora/chat-component gives you a production-oriented chat interface with:

  • Room list and room chat UI
  • Message history, replies, reactions, edits, deletes
  • Typing indicators
  • In-app notifications + Web Push integration
  • Configurable auth modes (default/login form/google/jwt/custom user)
  • Custom render components for message/input/scroll/day separator/new-message label

The package exports:

  • Chat (main component)
  • XmppProvider
  • useUnread
  • logoutService
  • useQRCodeChat, handleQRChatId
  • useInAppNotifications
  • usePushNotifications
  • resendMessage
  • useRoomMute

Why Ethora

Ethora provides hosted and customizable messaging infrastructure plus a wider product ecosystem.

Dimension Ethora Chat Component Full Ethora Platform
Primary goal Embed chat quickly in a React app End-to-end product stack (chat, profiles, wallets, AI, admin)
Time to first chat Minutes Higher initial setup, broader capabilities
Frontend scope Focused web chat UI package Multi-product ecosystem and broader SDK/tooling
Custom UI control High via props + custom components High, with additional platform-specific tooling
Best fit Support chat, portal messaging, embedded chat widget Full social/messaging app platforms with extended modules

Quick Start

1. Install

Tool Command
npm npm i @ethora/chat-component
yarn yarn add @ethora/chat-component
pnpm pnpm add @ethora/chat-component
bun bun add @ethora/chat-component

2. Render the chat

import { Chat, XmppProvider } from '@ethora/chat-component';
import './App.css';

export default function App() {
  return (
    <XmppProvider>
      <Chat />
    </XmppProvider>
  );
}

Required wrapper (XmppProvider)

Chat relies on internals that use useXmppClient(). In real integrations, wrap Chat (or your entire app shell) with XmppProvider:

import { Chat, XmppProvider } from '@ethora/chat-component';

export default function App() {
  return (
    <XmppProvider>
      <Chat config={{ baseUrl: 'https://api.chat.ethora.com/v1' }} />
    </XmppProvider>
  );
}

XmppProvider also accepts a pushNotifications prop for headless push setup (works even if Chat is not rendered yet):

import { XmppProvider } from '@ethora/chat-component';

export default function App() {
  return (
    <XmppProvider
      config={{ baseUrl: 'https://api.chat.ethora.com/v1', initBeforeLoad: true }}
      pushNotifications={{
        enabled: true,
        softAsk: false,
        vapidPublicKey: 'PLACEHOLDER_VAPID_PUBLIC_KEY',
        iconPath: '/icons/push-icon-192.png',
        badgePath: '/icons/push-badge-72.png',
        firebaseConfig: {
          apiKey: 'PLACEHOLDER_API_KEY',
          authDomain: 'PLACEHOLDER_AUTH_DOMAIN',
          projectId: 'PLACEHOLDER_PROJECT_ID',
          storageBucket: 'PLACEHOLDER_STORAGE_BUCKET',
          messagingSenderId: 'PLACEHOLDER_MESSAGING_SENDER_ID',
          appId: 'PLACEHOLDER_APP_ID',
        },
      }}
    >
      {/* Chat can be mounted later or omitted */}
      <div>App shell</div>
    </XmppProvider>
  );
}

Single XMPP Initialization Contract

To avoid duplicated wss://.../ws connections, keep a single XMPP init source:

  • initBeforeLoad: true -> XmppProvider is the only place that initializes XMPP.
  • initBeforeLoad: false -> Chat (useChatWrapperInit) initializes XMPP.

If your app also has external client.login(...) logic, guard it:

if (chatConfig.initBeforeLoad) {
  console.warn('[XMPP] initBeforeLoad=true, skip external client.login()');
  return;
}

await client.login(...);

Also pass a memoized config object to both XmppProvider and Chat:

const chatConfig = useMemo(() => ({ ...baseConfig }), [baseConfig]);

<XmppProvider config={chatConfig}>
  <Chat config={chatConfig} />
</XmppProvider>;

3. Run

npm run dev

Open http://localhost:5173.

Integration Modes

All modes below assume XmppProvider wraps Chat.

A) Minimal demo mode (provider + chat)

<XmppProvider>
  <Chat />
</XmppProvider>

Useful for local proof-of-concept and quick UI validation.

B) Auto default credential fallback (legacy behavior)

If no googleLogin, no jwtLogin, no userLogin, and no defaultLogin, LoginWrapper currently triggers internal email/password fallback logic.

<XmppProvider>
  <Chat config={{ colors: { primary: '#2563eb', secondary: '#dbeafe' } }} />
</XmppProvider>

C) Explicit email/password via user prop

<XmppProvider>
  <Chat
    user={{
      email: 'user@example.com',
      password: 'PLACEHOLDER_PASSWORD',
    }}
  />
</XmppProvider>

D) Injected logged-in user (userLogin)

<XmppProvider>
  <Chat
    config={{
      userLogin: {
        enabled: true,
        user: {
          _id: 'PLACEHOLDER_USER_ID',
          appId: 'PLACEHOLDER_APP_ID',
          walletAddress: 'PLACEHOLDER_WALLET_ADDRESS',
          defaultWallet: { walletAddress: 'PLACEHOLDER_WALLET_ADDRESS' },
          firstName: 'Jane',
          lastName: 'Doe',
          xmppPassword: 'PLACEHOLDER_XMPP_PASSWORD',
          token: 'PLACEHOLDER_ACCESS_TOKEN',
          refreshToken: 'PLACEHOLDER_REFRESH_TOKEN',
          username: 'PLACEHOLDER_USERNAME',
        },
      },
    }}
  />
</XmppProvider>

E) JWT login

<XmppProvider>
  <Chat
    config={{
      jwtLogin: {
        enabled: true,
        token: 'PLACEHOLDER_JWT_TOKEN',
      },
    }}
  />
</XmppProvider>

F) Google login

<XmppProvider>
  <Chat
    config={{
      googleLogin: {
        enabled: true,
        firebaseConfig: {
          apiKey: 'PLACEHOLDER_API_KEY',
          authDomain: 'PLACEHOLDER_AUTH_DOMAIN',
          projectId: 'PLACEHOLDER_PROJECT_ID',
          storageBucket: 'PLACEHOLDER_STORAGE_BUCKET',
          messagingSenderId: 'PLACEHOLDER_MESSAGING_SENDER_ID',
          appId: 'PLACEHOLDER_APP_ID',
        },
      },
    }}
  />
</XmppProvider>

G) Single-room entry + URL/QR behavior

<XmppProvider>
  <Chat
    roomJID="ROOM_JID@conference.xmpp.chat.ethora.com"
    config={{
      setRoomJidInPath: true,
      qrUrl: 'https://your-app.example/chat/?qrChatId=',
    }}
  />
</XmppProvider>

roomJID forces entry room.
setRoomJidInPath syncs room identity to URL path.
useQRCodeChat / handleQRChatId support QR/deep-link room opening.

Deep link to a chat and a message (?chatId= and ?messageId=)

When no roomJID is passed, the chat reads these query parameters of the page URL on load:

Parameter Meaning
chatId Room to open. A bare id is expanded with config.xmppSettings.conference; a full JID (contains @) is used as is. Without a conference domain in config a bare id is ignored.
messageId (alias msgId) Optional. Open the room and jump to that message, loading older history around it if it is not in memory yet, then briefly highlight it. Matched against the message id and xmppId. Without it the link just opens the room.

Example: https://your-app.example/chat?chatId=ROOM_ID&messageId=1781702272543611.

The same link shape is what a push notification click opens (see Push Notifications), so tapping a push lands on the exact message. Search results use the same jump.

Behavior Notes and Legacy Quirks

  • newArch is now default-on. If omitted, runtime uses new architecture paths.
  • Old architecture is used only when you explicitly set config.newArch = false.
  • defaultLogin currently has legacy inverted behavior in LoginWrapper:
    • internal fallback login runs when login modes are not configured and defaultLogin is not set.
    • keep this in mind when migrating; prefer explicit userLogin / jwtLogin / googleLogin.
  • Protocol: clients omit isSystemMessage on the <data> element of normal messages; isSystemMessage="true" marks system messages (call logs etc.). An absent or legacy "false" value is read as a normal message.

Chat Props Reference

These are the top-level props accepted by Chat (exported from ReduxWrapper).

Prop Type Required Notes
config IConfig No Main behavior/configuration object.
roomJID string No Force specific room JID on load.
scheduledCallSlug string No Open a scheduled call: once signed in, selects its room and shows the pre-join panel. The slug is the last part of a /call/<slug> join link. See Scheduled Calls.
user { email: string; password: string } No Credentials for email/password login helper path.
loginData { email: string; password: string } No Optional login payload.
MainComponentStyles React.CSSProperties No Outer container style override.
token string No Optional token input (legacy/integration-specific usage).
CustomMessageComponent React.ComponentType<MessageProps> No Replace message bubble rendering.
CustomInputComponent React.ComponentType<SendInputProps & { onSendMessage?; onSendMedia?; placeholderText?; }> No Replace chat input area.
CustomScrollableArea React.ComponentType<CustomScrollableAreaProps> No Replace list/scroll wrapper behavior.
CustomDaySeparator React.ComponentType<DaySeparatorProps> No Replace day separator node.
CustomNewMessageLabel React.ComponentType<NewMessageLabelProps> No Replace "new message" marker.

Full Config Reference (IConfig)

Below is a grouped reference for all config options.

Core

Option Type Description
appId string App identifier for backend context.
baseUrl string API base URL (defaults to https://api.chat.ethora.com/v1, the Ethora Cloud production endpoint).
customAppToken string Custom app token for API initialization.
xmppSettings { devServer; host; conference?; xmppPingOnSendEnabled? } XMPP connectivity settings.
initBeforeLoad boolean Initialize XMPP before normal chat load flow.
newArch boolean Defaults to true; set false to explicitly force legacy/old architecture paths.
useStoreConsoleEnabled boolean Enable verbose internal logging in console.
userLookupRoute 'auto' | 'v1' | 'v2' Route used to look up one sender that is not in the room member list. Default 'auto': GET /v1/apps/users/<xmppUsername> first; if the backend refuses the user token there (400/401, missing route) the chat uses GET /v2/chats/users?xmppUsername= for 10 minutes, then tries v1 again. A 403 from v1 is treated as a per-user "not allowed" and shows the name carried on the message, or "Unknown user". 'v1' / 'v2' pin one route.
trustedEventSenders string[] Who may push server ethora-event headlines (see Server events). Default (unset): any bare JID without a resource on the account's own XMPP domain, which is how the server sends them. Set it to pin the exact sender(s) as full bare JIDs (admin@xmpp.example.com) or local parts (admin). The chat-component-config-updated event comes from the bare server domain (see Server events), so a pinned list must name the domain (xmpp.example.com) for it to be heard.
remoteAppearance { appId: string; baseUrl?: string; initialWaitMs?: number } Read the App's look from the server and keep it current: the colours, fonts, layout switches and AI display options saved in the admin's Chat component tab. Opt in by naming the App. Read at start (the first paint waits for it, at most initialWaitMs, default 2500) and again on every chat-component-config-updated server event, applied to the open chat without reconnecting. Only the keys in Applying config live are accepted from the server. Off by default.
historyPreload { mode?: 'staged' | 'all' | 'off'; topRooms?: number; concurrency?: number } Background message-history preload after connect, for accounts with many rooms. Default { mode: 'staged', topRooms: 8, concurrency: 3 }. staged: the topRooms most recently active rooms get a full page loaded, concurrency at a time, and rooms the backend gave no lastMessage for get a one-message preview (up to 4x topRooms of them); every other room loads the moment it is opened. A room the user opens jumps the queue, and a reconnect only re-queues rooms that are not already preloaded. all: preload every room (the old behaviour; fine for a handful of rooms). off: no background preload at all. The room list's last message, time and unread badge come from GET /v1/chats/my (lastMessage, unreadCount) when the backend sends them, so they render without waiting for any history; a backend without those fields falls back to the history preload. The older historyQoS.preloadTopKRooms / stagedPreloadConcurrency are still honoured when the matching field here is not set; pinning historyQoS.stagedPreloadEnabled: false keeps the legacy startup catch-up path.
historyQoS.joinHistoryStanzas number Messages the MUC service replays on every room join (sent as <history maxstanzas="N"/>). Default 0: history comes from the message archive only, so connecting an account with many rooms no longer pulls the server default (up to 20 messages) per room. Raise it only if a room of yours has no archiving and relies on the join replay.
historyQoS.joinConcurrency number Rooms joined in parallel by the background join sweep. The room list is shown as soon as /chats/my returns; every room is then joined in the background (the open room first, then most recent activity; a room you open while it is queued jumps the queue). Default 5.
receipts { mode?: 'auto' | 'all' | 'off'; deliveryMaxRoomSize?: number; readMaxRoomSize?: number; maxAgeDays?: number; coalesceMs?: number } Which delivery (XEP-0184) and read (XEP-0333) receipts this client sends. The client always displays the receipts it receives. Defaults { mode: 'auto', deliveryMaxRoomSize: 20, readMaxRoomSize: 100, maxAgeDays: 7, coalesceMs: 1500 }. auto: delivery receipts only in rooms of up to deliveryMaxRoomSize other members, read markers only in rooms of up to readMaxRoomSize other members, and no delivery receipt for messages older than maxAgeDays. all: always send (still coalesced). off: send no delivery or read signal. Private 1:1 rooms and rooms of unknown size always send. Delivery receipts are sent at most once per coalesceMs window per room (the newest message in the window is the one acknowledged) and are not archived by the server. Read markers are sent at most every 3 seconds per room.

UI and Layout

Option Type Description
disableHeader boolean Hide chat header.
disableMedia boolean Disable media sending/processing paths.
attachments { maxFiles?; maxFileSizeMb?; accept?; captionMode? } Composer attachment limits. maxFiles defaults to 10 (also the hard cap, set by the stanza payload size); maxFileSizeMb adds a client-side per-file check (omit for none); accept is passed straight to the file picker. Picking several files sends them as one message. captionMode ('inline' | 'separate', default 'separate') sets where the text typed with the files goes: 'separate' sends the media message without the caption and then the text as a normal text message once the media send settles (also in e2ee rooms); 'inline' puts the caption on the media message (<data caption>, captionMentions). See Backend compatibility.
pdfPreview { enabled?; workerSrc?; libUrl?; maxFileSizeMb? } First-page PDF thumbnails inside bubbles and in the composer tray. On by default; enabled: false keeps the static document card. pdf.js is code-split and only fetched when a PDF actually shows up. By default it runs on the main thread, which needs no bundler asset wiring: set workerSrc to a pdf.js worker URL you host to move parsing off it. Set libUrl to the URL of a pdf.js ES module build to load it at runtime instead of the copy bundled with the component (pair it with workerSrc); useful when you ship the component as one self-contained script and do not want pdf.js (about 1.7 MB) inside it. maxFileSizeMb (default 25) is the size past which a document is not auto-rendered.
disableRooms boolean Hide/disable room list area.
disableRoomMenu boolean Disable room menu controls.
disableRoomMute boolean Hide the "Mute/Unmute notifications" toggle from the chat header menu and room profile, even on a backend that supports it.
disableRoomConfig boolean Hide every control that mutates a room's configuration, leaving the chat-details panel read-only: room avatar upload/remove, the "Delete chat" menu entry, the add-members action, and the per-member moderator menu (appoint as admin / remove member).
disableNewChatButton boolean Hide new chat/create room action.
enableMessageSearch boolean Opt in to message search (default off): the "Search messages" button in the chat header and chat profile, Ctrl/Cmd+F inside the chat, and message matches under the chat-list search box. The panel searches the platform's message archive (GET /v2/apps/{appId}/messages/search, available on the QA/prod backend) for the current chat or all chats and jumps to the hit. Needs appId; without one it stays off. A leftover disableMessageSearch: true (deprecated) still forces it off.
disablePublicChatsDirectory boolean Hide the "Discover chats" entry (in the room list menu, or as its own button when the host hides that menu with chatHeaderSettings.disableMenu or takes it over with headerMenu; disableRoomMenu removes it entirely): a directory of the app's public chats (GET /v1/chats/public, 50 per page, filtered client-side) where a chat can be joined without a link or QR code.
disableUserCount boolean Hide the member-count subtitle in the chat header ("N users", plus the online-users popover attached to it). The 1:1 online/offline line is a presence state, not a count, so it stays.
disableChatInfo { disableHeader?; disableDescription?; disableType?; disableMembers?; hideMembers?; disableChatHeaderMenu? } Fine-grained chat info panel toggles.
chatHeaderBurgerMenu boolean Toggle burger menu in chat header.
chatHeaderSettings { hide?; disableCreate?; disableMenu?; hideSearch? } Additional header-level controls.
chatHeaderAdditional { enabled: boolean; element: any } Inject custom element into header area.
headerLogo string | React.ReactElement Host logo rendered at the left of the chat header, before the back button / burger menu. A plain string is treated as an image URL and rendered as a 32px-tall <img> (aspect preserved); any other React element renders as-is. Same string-or-node duality as fallbackScreens.
headerMenu () => void Takes over the room-list burger menu. When set, the built-in Profile/Settings/Logout dropdown is not rendered: the same burger button calls your handler instead, so you can open your own account menu or drawer.
headerChatMenu () => void Same for the chat header's room menu (the "more" button carrying Report and Leave). When set, that dropdown is not rendered and the button calls your handler.
colors { primary; secondary; icons?; ownMessageBackground?; otherMessageBackground?; inputBackground? } Theme colours. primary drives icon colours and the sender name. icons themes every accent icon/icon-button (attach, mic, active send, new-chat, header, empty-state) — defaults to primary, so set a distinct value to decouple. ownMessageBackground/otherMessageBackground/inputBackground recolour the message bubbles and input bar.
backgroundChat { color?; image? } Background colour and/or image for the messages area. (image as a string URL; pass an object URL for a File.)
colorScheme 'light' | 'dark' | 'system' Light or dark UI. Default 'light' (the chat as before). 'system' follows the viewer's OS / browser setting and updates live. Scoped to the chat's own elements, so it never restyles the host page; a host with its own dark mode should pass its resolved choice. colors.icons, colors.iconsBg, the bubble/input colours and backgroundChat are used as given in both schemes. colors.primary is the one exception: in dark it is lightened slightly (about 20% toward white) so primary-coloured text and icons stay legible on the dark surface while buttons keep white text on them, and --ethora-color-primary-text carries a further-lifted variant for text-only uses such as links and sender names. Pass an already-dark-suitable primary if you need the exact brand value.
typography TypographyConfig Font family + size for the chat UI: fontFamily (or just googleFontsFamily) is applied across all chat text incl. messages and sender names; fontSize (px) scales message text, sender names, timestamps, inputs, room names and badges. Plus loaders (googleFontsUrl/fontFaces) and weight tokens.
fallbackScreens { noUser?; noConnection?; noRoom?; error? } Replace the built-in screens (Ethora login form, "Connecting...", empty room state, crash screen) with your own text or React nodes. A plain string is rendered as centered text. error also accepts a function ({ error, componentStack, scope, reset }) => ReactNode, see Error handling.
hiddenRooms { titles?: string[]; jids?: string[] } Hide specific rooms from the room list and unread counters (e.g. { titles: ['Main chat'] } to suppress the auto-created default room).
roomListStyles React.CSSProperties Styles for room list pane.
chatRoomStyles React.CSSProperties Styles for chat pane.
noMessagesPlaceholder React.ComponentType Replace empty-chat placeholder component (same render position as default placeholder).
backgroundChat { color?: string; image?: string | File } Chat background customization.
setRoomJidInPath boolean Sync room JID to URL path.
qrUrl string Base URL for QR deep link behavior.

Auth and Identity

Option Type Description
defaultLogin boolean Legacy quirk: current runtime fallback behavior is inverted; see Behavior Notes section.
googleLogin { enabled: boolean; firebaseConfig: FBConfig } Google login support via Firebase config.
jwtLogin { token: string; enabled: boolean; handleBadlogin?: React.ReactElement } Log user in using JWT exchange flow.
userLogin { enabled: boolean; user: User | null } Inject already-authenticated user directly.
customLogin { enabled: boolean; loginFunction: () => Promise<User | null> } Provide your custom async login function.
refreshTokens { enabled: boolean; refreshFunction?: () => Promise<{ accessToken: string; refreshToken?: string } | null> } Token refresh strategy.

Rooms and Data

Option Type Description
defaultRooms ConfigRoom[] Seed/default rooms.
enableRoomsRetry { enabled: boolean; helperText: string } Enable retry UX when rooms fail to load.

Messaging and Interactions

Option Type Description
disableInteractions boolean Disable message interaction menu/actions.
disableProfilesInteractions boolean Disable profile interactions from chat UI.
disableSentLogic boolean Disable default sent-state logic when needed.
secondarySendButton { enabled: boolean; messageEdit: string; label?: React.ReactNode; buttonStyles?: React.CSSProperties; hideInputSendButton?: boolean; overwriteEnterClick?: true } Extra send action/button config.
messageTextFilter { enabled: boolean; filterFunction: (text: string) => string } Transform/filter outgoing message text.
eventHandlers { onXmppEvent?; onMessageSent?; onMessageFailed?; onMessageEdited?; onError?; onChatComponentConfigUpdated? } Lifecycle callbacks for incoming XMPP messages and message operations. onXmppEvent receives every incoming <message/> stanza as raw XML plus its from, to, id, and type, including archive and protocol messages. On end-to-end encrypted messages the XML may still be encrypted. onError reports uncaught render errors caught by the SDK's error boundary, see Error handling; onChatComponentConfigUpdated({ appId, version }) runs when the server says the App's chat component config changed, see Applying config live.
translates { enabled: boolean; translations?: Iso639_1Codes; mode?: 'auto' | 'manual'; forceType?: boolean; readerLocale?: string; showLanguageSelector?: boolean; showLanguageList?: boolean; onTranslate?: (text, ctx) => Promise<string>; showTranslateForMessage?: (message) => boolean; endpoint?: string } Message translation-related options. In manual mode, an on-demand translate click shows exactly what mode: 'auto' would have shown automatically - the same message.translations lookup, no network call - unless onTranslate is provided, in which case it wins and may do its own HTTP. The built-in translate-service fetch is strictly opt-in: it only runs when the host sets endpoint explicitly (there is no default derived from config.baseUrl), and the result is cached onto the message so a second click, or switching to 'auto', sees it for free. The call is made directly from the browser, so endpoint must respond with Access-Control-Allow-Origin for that origin (a self-hosted translate service needs its CORS_ORIGIN env var set) or the fetch is blocked with no error shown beyond the retry link; if you can't set that on the endpoint, use onTranslate and proxy the call through your own backend instead. When there is genuinely nothing to reveal (no attached translation, no onTranslate, no endpoint), no "Translate" link is shown at all.
whitelistSystemMessage string[] Restrict/render only selected system message types.
customSystemMessage React.ComponentType<MessageProps> Replace system message component renderer.

Typing and Sending Control

Option Type Description
disableTypingIndicator boolean Disable typing indicator UI logic.
customTypingIndicator { enabled: boolean; text?: string | ((usersTyping: string[]) => string); position?: 'bottom' | 'top' | 'overlay' | 'floating'; styles?: React.CSSProperties; customComponent?: React.ComponentType<{ usersTyping: string[]; text: string; isVisible: boolean; }> } Customize typing indicator content and rendering.
blockMessageSendingWhenProcessing boolean | { enabled: boolean; timeout?: number; onTimeout?: (roomJID: string) => void } Gate sends while processing in-flight state.

Notifications

Option Type Description
inAppNotifications { enabled?; showInContext?; position?; maxNotifications?; duration?; onClick?; customComponent? } In-app toast notification behavior and custom rendering.

Push

Option Type Description
pushNotifications.enabled boolean Enable browser push subscription flow.
pushNotifications.vapidPublicKey string VAPID public key for push registration.
pushNotifications.firebaseConfig FBConfig Firebase app config for push messaging.
pushNotifications.serviceWorkerPath string Service worker path, default /firebase-messaging-sw.js.
pushNotifications.serviceWorkerScope string Service worker scope, default /.
pushNotifications.iconPath string Custom icon URL/path for OS push notifications.
pushNotifications.badgePath string Custom badge URL/path for OS push notifications. Falls back to iconPath.
pushNotifications.clickPath string Same-origin path a push tap opens when no window is open yet (for example /app/chat). The tapped room and message are appended as fromPush=1&chatId=...&messageId=... (ids only). Defaults to the origin root.
pushNotifications.softAsk boolean Do not immediately trigger browser permission prompt.
pushNotifications.onClick (params) => void | Promise<void> Callback invoked when the user clicks an OS push notification (including cold start via URL marker).

Backend compatibility

Some features call routes that not every backend deployment serves yet. Each switch below keeps that client code off until your backend has the route. The defaults are the safe choice for a backend that has not been updated.

Option Default Why
features.userBlock false /v1/chats/blocked is not served by the production backend yet (404), so the "Block user" item would always fail. Set true once your backend serves it.
attachments.captionMode 'separate' The caption goes out as its own text message after the media (the 26.9.8 wire shape), which older web builds, React Native and server push all show. 'inline' puts it on the media message as <data caption>, which those readers do not render yet.
uploads.endpoint 'auto' 'auto' tries /v2/files/secure and falls back to /v1/files/. Secure-files URLs can be unreadable on a backend that is not updated, so 'legacy' posts straight to /v1/files/. 'secure' never falls back.
Option Type Description
features { userBlock?: boolean } Opt-in backend features. userBlock (default false) shows the "Block user" item in 1-1 chat menus and enables the /v1/chats/blocked calls. disableUserBlock: true still hides the item.
uploads { endpoint?: 'auto' | 'secure' | 'legacy' } Attachment upload route. 'auto' (default) tries POST /v2/files/secure and falls back to POST /v1/files/ when the backend rejects it. 'legacy' always uses /v1/files/ (no chatName); 'secure' uses the secure route and never falls back.

Error handling and crash containment

The SDK renders inside your application's React tree. Without a boundary, one uncaught render error inside the chat would propagate to your root and unmount your whole app, so @ethora/chat-component ships an error boundary of its own:

  • one boundary wraps everything <Chat> renders (it sits inside <Chat>'s redux provider, above the persist gate and the whole chat tree);
  • a second one wraps the video/audio call overlay, which <XmppProvider> renders as a sibling of your children so it can ring while the user is on another page. A crash there disappears quietly instead of leaving a dead panel over your UI, and is still logged and reported.

The error is never swallowed: it is always written to the console together with React's component stack, regardless of useStoreConsoleEnabled.

Option Type Description
eventHandlers.onXmppEvent (event: XmppMessageEvent) => void | Promise<void> Called for every incoming XMPP <message/> before chat/protocol/MAM routing. The event contains the raw XML stanza and optional from, to, id, and type. Encrypted message XML may still be encrypted. Callback errors are caught and logged.
fallbackScreens.error React.ReactNode | ((ctx) => React.ReactNode) Replaces the built-in crash screen. A plain string renders as centered text, a React node renders as-is, a function receives { error, componentStack, scope, reset }.
eventHandlers.onError (event: ChatErrorInfo) => void Called with { error, componentStack, scope } when the boundary catches a crash. Wire it to your crash reporter. A throw inside your handler is caught and logged, never rethrown.

scope is 'chat' for the chat tree and 'call-overlay' for the call overlay. The built-in screen is themed from your colors / typography and translated through the same config.i18n locale as the rest of the UI, and it offers a "try again" button that clears the error and re-mounts the chat, since most of these failures are transient state problems.

<Chat
  config={{
    eventHandlers: {
      onError: ({ error, componentStack, scope }) =>
        Sentry.captureException(error, { extra: { componentStack, scope } }),
    },
    fallbackScreens: {
      // Simplest form: replace it with your own text.
      // error: 'Chat is temporarily unavailable',

      // Or take full control, including your own retry action:
      error: ({ error, reset }) => (
        <MyErrorPanel message={error.message} onRetry={reset} />
      ),
    },
  }}
/>

A string or node fallback is only used for the chat pane: for a crashed call overlay the SDK renders nothing, and only the function form (which can inspect scope) is consulted.

The call overlay is rendered by <XmppProvider>, so pass the same config object to the provider as to <Chat> (which the single XMPP initialization contract already recommends) if you want 'call-overlay' crashes to reach your onError too.

Server events (urn:ethora:events:1)

The server tells a user that something they display changed by sending a type="headline" message from its admin account with an <ethora-event xmlns="urn:ethora:events:1" type="..." .../> child. The payload is a pointer only; the chat re-reads the data through the normal API and never shows or stores anything from the stanza itself.

Event Attributes What the chat does
user-profile-updated xmppUsername (<appId>_<userId>), uuid, appId, ts Re-fetches that user, bypassing the cache and any "not found" memory, through the same lookup route as unknown senders (userLookupRoute). Existing bubbles are re-named and re-pictured. If it is the signed-in user, their own profile data is updated too. Bursts within 500 ms collapse into one request.
chat-meta-updated chatName (<appId>_<roomId>), appId, ts Re-fetches GET /v1/chats/my/<chatName> and applies only title, description, picture, type and user count to the room. Members, messages, last message and unread counters are never replaced, and the user count is never lowered. Events for rooms the chat does not hold are ignored. Debounced per room (500 ms), one request in flight, backoff after errors.
chat-component-config-updated appId, version, ts The admin saved a change in the Chat component tab. Calls config.eventHandlers.onChatComponentConfigUpdated({ appId, version }) and, when config.remoteAppearance is set, reads the config again and applies it live. Ignored unless appId is this session's own App (config.appId or remoteAppearance.appId). Once per version, a few hundred ms after the last of a burst. No values travel in the event.

Unknown event types and malformed attributes are ignored. Trust rule: the stanza must come from a bare JID (no resource) on the session's own XMPP domain, so room occupants, other users, the conference. domain and other servers are ignored; trustedEventSenders narrows this to named senders. chat-component-config-updated is the one event that is also accepted from the bare server domain itself (xmpp.example.com, no user part): the server delivers it to every online session of the host in a single announcement (ejabberd announce/online), so it also arrives for other Apps on the host and is dropped unless it names this session's App. A client cannot send as the server domain. xmppUsername and chatName are validated (charset, length, <appId>_ prefix) before use. The older <user-update> / <chat-update> headlines (namespace your:custom:ns) are still handled as before.

Applying config live, and remoteAppearance

Changing the config prop of a mounted <Chat> restyles it in place. Nothing reconnects, no room or message is reloaded, no draft is lost, and the signed-in session is left alone: the new config replaces the one in the store, the components that read it re-render, and the CSS variables, fonts and icon tint are re-published (a font or colour you remove goes back to the default). The connection and login are keyed on identity fields, not on the config object, so a host that rebuilds config on every render is already safe.

Applied live (change them any time) Read when the chat starts (a new value is used by the next mount)
colorScheme; colors.* (primary, secondary, icons, iconsBg, ownMessageBackground, otherMessageBackground, inputBackground); typography.*; backgroundChat.* appId, baseUrl, customAppToken, xmppSettings.* (these also pick the cache scope, a different one clears the local cache)
disableHeader, disableMedia, disableRooms, disableRoomMenu, disableNewChatButton, disableUserCount, disableTypingIndicator, disableProfilesInteractions, chatHeaderBurgerMenu, chatHeaderSettings.hideSearch userLogin, jwtLogin, customLogin, refreshTokens, initBeforeLoad, initBeforeLoadAuth
blockMessageSendingWhenProcessing, customTypingIndicator.enabled / .text / .position, translates.enabled, secondarySendButton.enabled newArch, historyQoS, historyPreload, disableLastRead, receipts, e2ee, videoCalls, pushNotifications

Anything not in the left column is not applied from the server by remoteAppearance either.

remoteAppearance. The Ethora admin saves these options per App (PUT /v2/apps/<appId>/chat-component/config), and the server tells every online chat of that App with the chat-component-config-updated event. Opt in and the chat does the rest:

<Chat config={{ appId, baseUrl, remoteAppearance: { appId } /* baseUrl defaults to config.baseUrl */ }} />

It reads GET /v2/chat-component/config?appId= (public, no token) at start and holds the first paint for it, at most initialWaitMs (2500), so the chat starts once with its final look; the saved values win over the same keys in your config. On every event it reads again with &v=<version> (which gets past the endpoint's 30 s cache) and applies the answer live. A failed read keeps the look you have. Only the left-column keys are taken from the answer, each checked (colours, font names and sizes, a few enums and booleans), so a wrong answer cannot do more than restyle. The record is kept in the store next to config, so a second writer of config (for example <XmppProvider config>) does not undo it.

Prefer to fetch yourself? Leave remoteAppearance out, read the endpoint in the callback and pass the merged result as config:

<Chat config={{ ...base, ...saved, eventHandlers: { onChatComponentConfigUpdated: ({ appId, version }) => reload(appId, version) } }} />

Delivery is best effort, like any push: a client that was offline misses the event and picks the change up at its next start.

Custom Widgets and Overrides

You can replace key UI parts without forking the package.

Override prop Purpose
CustomMessageComponent Fully custom message bubble/row rendering.
CustomInputComponent Custom composer and send controls.
CustomScrollableArea Custom scroll/list container (virtualized or custom behavior).
CustomDaySeparator Custom date separator component.
CustomNewMessageLabel Custom "new message" divider label.

Example:

import { Chat } from '@ethora/chat-component';
import CustomMessageBubble from './CustomMessageBubble';
import CustomChatInput from './CustomChatInput';
import CustomScrollableArea from './CustomScrollableArea';
import CustomDaySeparator from './CustomDaySeparator';
import CustomNewMessageLabel from './CustomNewMessageLabel';

export default function App() {
  return (
    <Chat
      CustomMessageComponent={CustomMessageBubble}
      CustomInputComponent={CustomChatInput}
      CustomScrollableArea={CustomScrollableArea}
      CustomDaySeparator={CustomDaySeparator}
      CustomNewMessageLabel={CustomNewMessageLabel}
      config={{
        colors: { primary: '#1d4ed8', secondary: '#dbeafe' },
      }}
    />
  );
}

Reference example components in repository:

  • src/examples/customComponents/CustomMessageBubble.tsx
  • src/examples/customComponents/CustomChatInput.tsx
  • src/examples/customComponents/CustomScrollableArea.tsx
  • src/examples/customComponents/CustomDaySeparator.tsx
  • src/examples/customComponents/CustomNewMessageLabel.tsx

Scheduled Calls

Scheduled calls let a member book a call in a chat. Everyone in the room gets an invite card with "Google Calendar", "Outlook" and "Download .ics" links, and a Join button that opens 15 minutes before the start. Booking only decides when: the call itself runs on the existing call path (1:1 rooms over P2P WebRTC, group rooms over LiveKit), and no camera or microphone is touched until the user taps Join on the pre-join panel. Calendar delivery is .ics and template links only; there is no calendar OAuth.

It needs a backend that serves the scheduled-call endpoints (/v1/chats/calls/scheduled) and is on by default whenever config.videoCalls.enabled is on:

<Chat
  config={{
    videoCalls: { enabled: true, livekitUrl: 'wss://livekit.example.com', enableAudioCalls: true },
    // scheduledCalls is optional; these are the defaults.
    scheduledCalls: { enabled: true },
  }}
/>

What the user sees:

  • A "Schedule call" button next to the call buttons in the chat header (same room types and gating as the call buttons). The form takes a title, date and time in the local time zone, a duration (15 minutes to 2 hours, or the call's own when editing) and video or audio (audio only when videoCalls.enableAudioCalls is on).
  • An invite card in the room for each scheduled call. An edit or a cancel from the backend replaces the card (one card per call, the newest status wins): upcoming, open to join, ended (greyed) or cancelled (struck through). The person who scheduled the call (the message names them in scheduledCallCreatedBy, so no request is needed) and the room owner also get Edit and Cancel.
  • Join opens the pre-join panel (title, time, duration, room, calendar links) and a "Join call" button, enabled from 15 minutes before the start until the call ends.

Clients that do not know the message type show its plain-text body, so older apps stay readable.

Opening a call from a link

A join link looks like https://<web app host>/call/<slug>. Pass the slug to <Chat> and, once the user is signed in and the room list has loaded, the chat selects that room and shows the pre-join panel. Nothing starts until the user taps Join.

import { Chat, parseScheduledCallLink, openScheduledCall } from '@ethora/chat-component';

// Declarative: the route /call/:slug renders the chat with the slug.
<Chat config={config} scheduledCallSlug={slug} />

// Imperative: from a deep-link handler, any time after import.
const slug = parseScheduledCallLink(url); // null when `url` is not a join link
if (slug) openScheduledCall(slug);

parseScheduledCallLink(url, options?) accepts https://<host>/call/<slug> and <scheme>://call/<slug> (never plain http), on app.chat.ethora.com, the page's own host and options.hosts (or, when omitted, config.scheduledCalls.hosts); schemes follow options.schemes or the configured ones. A request made before sign-in or before <Chat> is mounted is held and opened once both are true. A slug that cannot be opened (unknown, not a member, cancelled, offline) shows a toast and calls config.scheduledCalls.onOpenError({ slug, reason }) with reason one of 'not-found' | 'not-a-member' | 'cancelled' | 'network'. A host that wants its own error screens can call GET /v1/chats/calls/scheduled/:slug itself first (as ethora-app-reactjs does) and only mount the chat for a call that can be opened.

Option Type Description
scheduledCalls.enabled boolean Default true when videoCalls.enabled is on. false hides the "Schedule call" button and the Join button on cards; cards still render with their calendar links.
scheduledCalls.hosts string[] Extra hosts whose https://<host>/call/<slug> links parseScheduledCallLink accepts, on top of app.chat.ethora.com and the page's own host. Same option and defaults as the React Native SDK.
scheduledCalls.schemes string[] App URL schemes (without ://) accepted for <scheme>://call/<slug>. Unset accepts any scheme.
scheduledCalls.onOpenError (e: { slug: string; reason: ScheduledCallOpenError }) => void Called when scheduledCallSlug / openScheduledCall could not open a call.

Notes: the invite message is sent by the backend in the clear, also in e2ee rooms, like other system messages. Calendar links on a card are accepted only when they are plausible (https calendar provider hosts, and an .ics link on the API's own origin), because any member could forge the message attributes.

Push Notifications

Prerequisites

Requirement Why
HTTPS origin (or localhost) Browser push APIs require secure contexts.
Firebase project FCM token + push transport setup.
VAPID public key Required for web push subscription.
Service worker file Required for background notification handling.

Setup steps

  1. Copy service worker into your app's public assets:
npx @ethora/chat-component ethora-chat
  1. Configure push in config:
<Chat
  config={{
    pushNotifications: {
      enabled: true,
      vapidPublicKey: 'PLACEHOLDER_VAPID_PUBLIC_KEY',
      firebaseConfig: {
        apiKey: 'PLACEHOLDER_API_KEY',
        authDomain: 'PLACEHOLDER_AUTH_DOMAIN',
        projectId: 'PLACEHOLDER_PROJECT_ID',
        storageBucket: 'PLACEHOLDER_STORAGE_BUCKET',
        messagingSenderId: 'PLACEHOLDER_MESSAGING_SENDER_ID',
        appId: 'PLACEHOLDER_APP_ID',
      },
      serviceWorkerPath: '/firebase-messaging-sw.js',
      serviceWorkerScope: '/',
      iconPath: '/icons/push-icon-192.png',
      badgePath: '/icons/push-badge-72.png',
      softAsk: false,
      onClick: async ({ roomJID, messageId, url, data }) => {
        // Your app-level routing/analytics can live here.
        console.log('Push clicked:', { roomJID, messageId, url, data });
      },
    },
  }}
/>
  1. Optional: use hook directly for controlled permission flow:
import { usePushNotifications } from '@ethora/chat-component';

function PushPermissionButton() {
  const { requestPermission } = usePushNotifications({
    enabled: true,
    softAsk: true,
    vapidPublicKey: 'PLACEHOLDER_VAPID_PUBLIC_KEY',
  });

  return <button onClick={() => requestPermission()}>Enable Push</button>;
}

iconPath and badgePath should point to public, reachable assets (for example, files from your app public/ directory).

Which icon a web push shows

public/firebase-messaging-sw.js picks the notification icon in this order:

  1. An icon the push itself carries: data.icon, then notification.icon, then icon on the payload. A push about a specific chat or sender can therefore show that chat's avatar. Only http(s) URLs and same-origin paths (starting with a single /) are accepted; anything else (data:, javascript:, a bare word) is ignored.
  2. pushNotifications.iconPath.
  3. The default, /favicon-192.png on your origin.

The badge has no per-push override: it is pushNotifications.badgePath, then the default /favicon-192.png. Make sure the fallback file exists on your origin, or the OS shows a generic icon.

Clicking a push opens <origin>/chat?chatId=<room>&messageId=<message> (or the url carried by the push) and calls pushNotifications.onClick; see the deep link section above.

Incoming call pushes (web)

With videoCalls.enabled: true and push enabled, a data-only call push (type: "call", callId, callToken, room, kind, callerName, callerXmppUsername) rings the same way as on mobile:

  • The worker asks every open window to ring (CALL_PUSH). A visible page that shows the ring answers (CALL_PUSH_ACK) and nothing else is shown. With no ack within 1.5 s (no window, a hidden tab, a dead socket) the worker shows a sticky notification "Incoming video call" with Answer and Decline actions, tag call:<callId> (call:room:<room> when the push has no callId). A page that already has that call connecting or in progress acks it, hidden or not, so a duplicate push never rings again; a page busy with a different call acks it as busy and the worker shows a plain "Missed call" instead of a ring. A push without callToken cannot be answered: it shows Decline only, and a tap opens the room. A tap or Answer with no window opens pushNotifications.clickPath (the page that mounts the chat); the call itself travels in the pending click, never in the url.
  • The push has no TTL, so freshness is read from the exp of callToken. With less than 3 s left nothing rings and a plain "Missed video call" notification (tag missed-call:<room>, tap opens the room) is shown instead. A token with no readable exp is treated as fresh. A ring the worker shows is never sticky for longer than 60 s (the backend's TTL for a call): at exp, or 60 s after the push when exp is unreadable or further away, it is replaced by the "Missed call" notification. A page that times a ring out in a hidden tab shows the same "Missed call" notification itself (same tag, shown once), so the result does not depend on whether the page or the worker gets there first.
  • Answer focuses or opens the app, raises the call and accepts it at once, so LiveKit connects with the push token (the browser may ask for microphone or camera). Tapping the notification body shows the ring screen. The click goes to every open window that announced it takes calls (the page has the call push listener mounted with videoCalls.enabled; CALL_CLIENT_READY), and the one that took it answers CALL_PUSH_ANSWER_ACK and is focused; a host page with no <Chat> is never picked just because it was focused last. With no ready window the worker does not wait: it keeps the click as the pending click and focuses and navigates an open tab to pushNotifications.clickPath (or opens it), and the new page accepts the call. The same happens when a ready window does not ack within 1.5 s, except that a refused openWindow (the click's user activation is short-lived) leaves the pending click for the next page load (2 min). The focus and the no-ready fallback run while the click is fresh; only the ack wait can outlast it, which is why a tab that was ready but silent is never the one navigated.
  • Decline goes to every open window the same way. The page that takes it (XMPP call-state plus POST /v1/chats/call/state/decline/:callId) answers CALL_PUSH_DECLINE_ACK; if no ack comes within 1.5 s (a host page with no <Chat>, the config not loaded yet) the worker declines over REST itself. With no page open the worker calls that endpoint directly, using the user token the page mirrors into Cache Storage (the token the SDK already keeps in this origin's localStorage; deleted on logout). Without that token Decline only closes the notification and the caller times out.
  • Several tabs of one origin can ring for the same call. Accepting or declining takes one per-call claim (a Web Lock), whether it comes from the notification's Answer and Decline or from the Accept and Decline buttons on the ring screen, so two tabs never both accept. A tab that does not get the claim does nothing. The tab that accepts, declines or sees the caller cancel broadcasts the outcome to the other tabs of the origin (on a BroadcastChannel named ethora-call and, for tabs without BroadcastChannel, as a localStorage write picked up by the storage event; a tab acts on whichever arrives first), and a tab still ringing for that call ends its ring quietly: no "Missed call", nothing sent to the caller, its notification closed. A ring that merely runs out is each tab's own. The outcome is also remembered for about 2 minutes (call keys and timestamps only, in localStorage), so a late push, a stale Answer or Decline, or a replay of the call token never raises, accepts or hangs up a call another tab already handled. A call with no callId is keyed by its room and is not remembered. Without Web Locks the tabs behave as before, and only the remembered outcome protects against a second accept.
  • Set pushNotifications.clickPath to the route where <Chat> is mounted (for example /chat), even though it is optional. It decides where Answer, a tap or a refused openWindow goes when no window that can take calls is open. It defaults to the origin root, which in most apps is a landing page or a login screen that does not mount <Chat>: the worker then opens a page that never hears the call push listener, so Answer lands on that page, nothing accepts, the pending click waits for the next page load (2 min) and the call is lost for the user. With clickPath pointing at the chat route, the new page mounts the chat, picks up the pending click and accepts the call. The same applies to a tab that is open on a page without <Chat>: it is not offered the click, the worker navigates it to clickPath instead.
  • A ring a page shows through the service worker registration (a hidden tab with a live socket) is persistent, so the page tells the worker (CALL_RING_SHOWN, with the call's exp and no token) and the worker ends it like a ring of its own, even if the tab is closed in the meantime. A "Missed call" shown by the page and by the worker at the same moment is shown once (both check and show under one Web Lock).
  • The call notification is closed whenever the ring ends (answered, declined, cancelled, timed out). A push that arrives before the chat config and the user are in Redux is held until they are, or until its token expires. On logout (or a switch to another account) the held call, the tap the worker keeps for the next page load and every call notification on screen are cleared.

Auth Strategies

Strategy Config shape Best for
Default fallback (legacy quirk) no auth block / defaultLogin Legacy/demo flows; prefer explicit auth modes in production
Injected user userLogin: { enabled: true, user } App already has authenticated user/session
JWT login jwtLogin: { enabled: true, token } Token-based backend auth flow
Google login googleLogin: { enabled: true, firebaseConfig } Google SSO using Firebase
Custom login function customLogin: { enabled: true, loginFunction } Fully custom identity provider

Example: injected user (bypass login screen)

<Chat
  config={{
    userLogin: {
      enabled: true,
      user: {
        _id: 'PLACEHOLDER_USER_ID',
        appId: 'PLACEHOLDER_APP_ID',
        walletAddress: 'PLACEHOLDER_WALLET_ADDRESS',
        defaultWallet: { walletAddress: 'PLACEHOLDER_WALLET_ADDRESS' },
        firstName: 'Jane',
        lastName: 'Doe',
        xmppPassword: 'PLACEHOLDER_XMPP_PASSWORD',
        token: 'PLACEHOLDER_ACCESS_TOKEN',
        refreshToken: 'PLACEHOLDER_REFRESH_TOKEN',
        username: 'PLACEHOLDER_USERNAME',
      },
    },
  }}
/>

Example: JWT login

<Chat
  config={{
    jwtLogin: {
      enabled: true,
      token: 'PLACEHOLDER_JWT_TOKEN',
    },
    refreshTokens: {
      enabled: true,
      refreshFunction: async () => {
        return {
          accessToken: 'PLACEHOLDER_NEW_ACCESS_TOKEN',
          refreshToken: 'PLACEHOLDER_NEW_REFRESH_TOKEN',
        };
      },
    },
  }}
/>

Hooks and API Exports

Export Type Purpose
Chat React component Main chat component.
XmppProvider React provider Provides XMPP client context for internal hooks/state.
useUnread hook Returns unread counters (totalCount, per-room maps) plus a loading flag that stays true until the room list has loaded at least once. totalCount/hasUnread exclude any room the caller has muted - a muted room still reports its own count in unreadByRoom/displayByRoom, it just doesn't inflate the aggregate.
logoutService service Programmatic logout utility.
useQRCodeChat hook Handle QR-based room links.
handleQRChatId function Parse/process QR chat ID from URL.
useInAppNotifications hook Enables and handles in-app notifications.
usePushNotifications hook Push subscription + foreground handling workflow.
resendMessage function Retry sending failed/pending messages.
parseScheduledCallLink function parseScheduledCallLink(url) returns the slug of a scheduled-call join link (https://<host>/call/<slug> or <scheme>://call/<slug>), or null.
openScheduledCall function openScheduledCall(slug) asks the mounted <Chat> to select that call's room and show its pre-join panel; held until the user is signed in. Returns false for an implausible slug.
useRoomMute hook useRoomMute(roomJid) returns { muted, isSupported, isPending, toggleMute, setMuted } for a room's mute preference - optimistic with rollback on failure. isSupported is only true once the backend has reported a muted boolean for that room (it hasn't shipped to prod yet), so hosts building their own UI should hide a mute control until then.

Basic hook usage:

import { useUnread, logoutService } from '@ethora/chat-component';

function HeaderActions() {
  const { totalCount, loading } = useUnread();

  return (
    <div>
      <span>{loading ? 'Loading…' : `Unread: ${totalCount}`}</span>
      <button onClick={() => logoutService.performLogout()}>Logout</button>
    </div>
  );
}

logoutService.performLogout() behavior:

  • Dispatches chatSettingStore/logout
  • Dispatches rooms/setLogoutState
  • Dispatches roomHeap/clearHeap
  • Triggers logout middleware, which emits ethora-xmpp-logout
  • XmppProvider listens to that event and disconnects active XMPP client

Use Cases and Feature Coverage

Area Status in this package
One-room embedded chat Available
Multi-room chat UI Available
Message interactions (reply/copy/edit/delete/report/reactions) Available
Typing indicator Available
Profile interactions in chat Available (can be disabled)
File/media attachments Available with ongoing enhancements
In-app notifications Available
Web push notifications Available
Wallet/assets and extended social modules Primarily in full Ethora platform

Hosted vs Self-Host Guidance

Model Best for Pros Tradeoffs
Hosted Ethora backend Fast time-to-market, smaller teams, MVPs Fast setup, managed backend operations, easier push/auth onboarding Less infrastructure-level control
Self-hosted Ethora stack Regulated environments, deep infra control Full control over infrastructure, compliance customization, internal network deployment options Higher DevOps/maintenance overhead
Hybrid Gradual migration or split workloads Can start fast and migrate critical paths later More architecture complexity

Feature Roadmap

This is a practical planning snapshot for cross-platform consumers. It is not a release commitment.

Surface Current state Notes
Web React (@ethora/chat-component) Available now This repository.
React Native Via broader Ethora stack Track platform-specific implementation in Ethora repos/docs.
Swift (iOS native) Planned / ecosystem-level Confirm status with Ethora team for production timelines.
Kotlin (Android native) Planned / ecosystem-level Confirm status with Ethora team for production timelines.
Flutter Planned / ecosystem-level Confirm status with Ethora team for production timelines.
Additional roadmap items Ongoing Media improvements, richer profile/wallet experiences, broader integration guides.

Troubleshooting

Issue Likely cause Fix
useXmppClient must be used within an XmppProvider Using internal XMPP-dependent logic without provider context Wrap the app tree with XmppProvider where needed.
Chat loads but no rooms appear Auth/app context mismatch or room fetching restrictions Verify appId, user credentials/tokens, baseUrl, and defaultRooms settings.
Push permission never appears softAsk: true without manual trigger, insecure origin, or missing VAPID key Trigger requestPermission(), use HTTPS/localhost, set valid VAPID key.
Service worker not found firebase-messaging-sw.js missing in public dir Run npx @ethora/chat-component ethora-chat or copy file manually.
Login loop / auth failure Wrong token/user object shape Validate jwtLogin, userLogin.user, and refresh token flow.

Testing

Same two-layer split as the Android and iOS SDKs.

Layer 1 — Vitest + React Testing Library (this repo)

Hermetic component tests, no real server, no XMPP. Run with:

npm test          # one-shot
npm run test:watch # watch mode
Where What Run with
src/**/*.test.tsx Component tests using @testing-library/react — render the component in isolation, drive it with userEvent, assert behavior npm test
src/test/setup.ts Vitest global setup (jest-dom matchers, jsdom polyfills for matchMedia / IntersectionObserver / scrollIntoView) (loaded automatically)
src/test/renderWithProviders.tsx Test render helper that wraps a component with a fresh Redux store + ToastProvider — no persisted-store / saga / XMPP middleware leaking between tests (imported by tests)
src/test/testIds.ts Stable data-testid constants matching Android *TestTags and iOS *AccessibilityID, so a single Maestro flow exercises the same intent on either mobile platform (imported by tests + components)

Current Layer-1 coverage

Component Test Asserts
<Login /> renders email + password fields and submit button data-testid selectors all resolve
<Login /> shows email validation error for an invalid email Bad email → error message + no API call fires
<Login /> shows password length error for short passwords Password < 6 chars → error + no API call
<Login /> calls loginEmail with valid credentials Valid creds → mocked loginEmail called once with the right args

Gaps to cover in follow-up PRs (file an issue + a test in the same PR when you tackle one):

  • <Register /> — same shape as Login + Google sign-up branch
  • <MessageBubble /> — body / deleted / sendFailed / reaction states
  • <RoomList /> — search filter, active-room highlight, badge counts
  • <MessageInput /> (chat input) — send callback fires, edit mode, reply mode, disabled state
  • <URLPreviewCard /> — link extraction + image fallback

Layer 2 — End-to-end Playwright

The host app ethora-app-reactjs runs a Playwright suite. Its current scope is public-page smoke (login / register / 404). The chat-component flows themselves are not yet covered by Playwright — when they are, those tests should resolve nodes via the same data-testid values exported from src/test/testIds.ts here.

Cross-platform parity

Identifier This repo Android iOS
chat_input ChatInputTestIds.inputField ChatInputTestTags.INPUT_FIELD ChatInputAccessibilityID.inputField
chat_send_button ChatInputTestIds.sendButton ChatInputTestTags.SEND_BUTTON ChatInputAccessibilityID.sendButton
chat_attach_button ChatInputTestIds.attachButton ChatInputTestTags.ATTACH_BUTTON ChatInputAccessibilityID.attachButton
chat_message_image MessageBubbleTestIds.mediaContent MessageBubbleTestTags.MEDIA_CONTENT MessageBubbleAccessibilityID.mediaContent
rooms_list RoomListTestIds.roomsList RoomListViewTestTags.ROOMS_LIST RoomListAccessibilityID.roomsList
room_row RoomListTestIds.roomRow RoomListViewTestTags.ROOM_ROW RoomListAccessibilityID.roomRow
auth_email_input AuthTestIds.emailInput (web-only) (web-only)
auth_submit_button AuthTestIds.submitButton (web-only) (web-only)

Adding a test for a fix or new feature

  • Behavior bug in a chat component → add a Vitest test in this repo, in the same PR as the fix.
  • Integration bug (something a host app sees but a hermetic test can't) → add a Playwright test in ethora-app-reactjs, in a paired PR.
  • Cross-platform parity gap → make sure the matching Android Compose test or Maestro flow exists too.

Ethora Links and Support

Product

Developer Docs

Community and Support

License

MIT. See LICENSE.

About

NPM package "Ethora Chat Component". React.js. Quickly deploy a chat interface embedded into your web portal, as a website pop-up widget or a stand-alone app. Available via npm registry: https://www.npmjs.com/package/@ethora/chat-component

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

11 stars

Watchers

3 watching

Forks

Releases

Packages

Used by

Contributors

Languages