Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 6 additions & 11 deletions AI.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ const CustomMessage = () => {

**Steps**:

1. Install emoji packages: `npm install emoji-mart @emoji-mart/react @emoji-mart/data`
1. Install emoji packages: `npm install emoji-mart @emoji-mart/data`
2. Initialize emoji data: `init({ data })` from `emoji-mart`
3. Import `EmojiPicker` from `stream-chat-react/emojis`
4. Pass `EmojiPicker` and `emojiSearchIndex={SearchIndex}` to `Channel`
Expand All @@ -260,8 +260,6 @@ init({ data });
</Channel>;
```

**Note**: For React 19, may need package.json overrides for `@emoji-mart/react`

**Reference**: See `examples/tutorial/src/6-emoji-picker/`

## TypeScript Setup
Expand Down Expand Up @@ -401,14 +399,11 @@ interacts with `<Channel>` / `<Thread>` request-handler props, and why there is
## Package Information

- **Package Name**: `stream-chat-react`
- **Peer Dependencies**:
- `react`: ^19.0.0 || ^18.0.0 || ^17.0.0
- `react-dom`: ^19.0.0 || ^18.0.0 || ^17.0.0
- `stream-chat`: ^9.27.2
- **Optional Dependencies** (for emoji support):
- `emoji-mart`: ^5.4.0
- `@emoji-mart/react`: ^1.1.0
- `@emoji-mart/data`: ^1.1.0
- **Peer Dependencies**: `react`, `react-dom`, `stream-chat`
- **Optional Peer Dependencies** (for emoji support): `emoji-mart`, `@emoji-mart/data`
- **Versions**: read the supported ranges from `peerDependencies` in the package's `package.json`
(`node_modules/stream-chat-react/package.json` in an app). They change with every release, so
they are not repeated here.

## Best Practices

Expand Down
5 changes: 0 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@
"peerDependencies": {
"@breezystack/lamejs": "^1.2.7",
"@emoji-mart/data": "^1.1.0",
"@emoji-mart/react": "^1.1.0",
"emoji-mart": "^5.4.0",
"modern-normalize": "^3.0.1",
"react": "^19.0.0 || ^18.0.0 || ^17.0.0",
Expand All @@ -140,9 +139,6 @@
"@emoji-mart/data": {
"optional": true
},
"@emoji-mart/react": {
"optional": true
},
"emoji-mart": {
"optional": true
},
Expand All @@ -161,7 +157,6 @@
"@commitlint/cli": "^21.0.1",
"@commitlint/config-conventional": "^21.0.1",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@eslint/js": "^9.39.4",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
Expand Down
12 changes: 2 additions & 10 deletions src/plugins/Emojis/EmojiPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import PickerImport from '@emoji-mart/react';
import { Picker, type PickerProps } from './Picker';

import {
useComponentContextIcons,
Expand All @@ -14,14 +14,6 @@ import {
import { usePopoverPosition } from '../../components/Dialog/hooks/usePopoverPosition';
import { useIsCooldownActive } from '../../components/MessageComposer/hooks/useIsCooldownActive';

// @emoji-mart/react ships as CJS with the component on `exports.default`. Under
// spec-strict ESM interop (e.g. Vite 8 / Rolldown, native Node ESM) a default
// import yields the module namespace `{ default }` instead of the component,
// which makes React throw "Element type is invalid ... got: object". Unwrap the
// default defensively so it works regardless of interop.
const Picker =
(PickerImport as unknown as { default?: typeof PickerImport }).default ?? PickerImport;

const isShadowRoot = (node: Node): node is ShadowRoot => !!(node as ShadowRoot).host;

export type EmojiPickerProps = {
Expand All @@ -34,7 +26,7 @@ export type EmojiPickerProps = {
* Untyped [properties](https://github.com/missive/emoji-mart/tree/v5.5.2#options--props) to be
* passed down to the [emoji-mart `Picker`](https://github.com/missive/emoji-mart/tree/v5.5.2#-picker) component
*/
pickerProps?: Partial<{ theme: 'auto' | 'light' | 'dark' } & Record<string, unknown>>;
pickerProps?: Partial<{ theme: 'auto' | 'light' | 'dark' } & PickerProps>;
/**
* Floating UI placement (default: 'top-end') for the picker popover
*/
Expand Down
32 changes: 32 additions & 0 deletions src/plugins/Emojis/Picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useEffect, useRef } from 'react';
import { Picker as EmojiMartPicker } from 'emoji-mart';

/**
* Untyped [properties](https://github.com/missive/emoji-mart/tree/v5.5.2#options--props) forwarded
* to the emoji-mart `Picker` custom element.
*/
export type PickerProps = Record<string, unknown>;

// React wrapper around the emoji-mart `Picker` custom element. Taken and adjusted from
// @emoji-mart/react (MIT, Copyright (c) Missive):
// https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/packages/emoji-mart-react/react.tsx
//
// Vendored rather than depended upon because @emoji-mart/react does not declare React 19 in its
// peer dependencies, which forces consumers into `package.json` overrides.
export const Picker = (props: PickerProps) => {
const ref = useRef<HTMLDivElement | null>(null);
const instance = useRef<EmojiMartPicker | null>(null);
if (instance.current) {
instance.current.update(props);
}

useEffect(() => {
instance.current = new EmojiMartPicker({ ...props, ref });
return () => {
instance.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return <div ref={ref} />;
};
105 changes: 105 additions & 0 deletions src/plugins/Emojis/__tests__/Picker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React, { StrictMode } from 'react';
import { render, waitFor } from '@testing-library/react';
import { Picker } from '../Picker';

// Minimal payload in the shape emoji-mart expects, so the picker can initialize without
// pulling in the full @emoji-mart/data set.
const data = {
aliases: {},
categories: [{ emojis: ['grinning'], id: 'people' }],
emojis: {
grinning: {
id: 'grinning',
keywords: ['face', 'smile'],
name: 'Grinning Face',
skins: [{ native: '馃榾', unified: '1f600' }],
version: 1,
},
},
sheet: { cols: 60, rows: 60 },
};

const pickerElements = (container: HTMLElement) =>
container.querySelectorAll('em-emoji-picker');

const getRenderedPicker = async (container: HTMLElement) => {
await waitFor(() => expect(pickerElements(container)).toHaveLength(1));
const element = container.querySelector('em-emoji-picker');
// emoji-mart renders into a shadow root from an async `connectedCallback`, so wait for
// the UI itself rather than just the custom element wrapper.
await waitFor(() =>
expect(element?.shadowRoot?.querySelector('input[type="search"]')).toBeTruthy(),
);
return element;
};

describe('Emojis/Picker', () => {
const OriginalIntersectionObserver = globalThis.IntersectionObserver;

beforeEach(() => {
// emoji-mart observes emoji category rows to lazy-render them; jsdom has no
// IntersectionObserver, and without a stub the picker's componentDidMount rejects.
// @ts-expect-error intersection observer stubs
globalThis.IntersectionObserver = class MockIntersectionObserver implements IntersectionObserver {
root = null;
rootMargin = '';
thresholds = [];
disconnect = vi.fn();
observe = vi.fn();
takeRecords = vi.fn(() => []);
unobserve = vi.fn();
};
});

afterEach(() => {
globalThis.IntersectionObserver = OriginalIntersectionObserver;
});

it('mounts exactly one emoji-mart picker element', async () => {
const { container } = render(<Picker data={data} />);
await getRenderedPicker(container);
});

it('mounts exactly one emoji-mart picker element under StrictMode', async () => {
// StrictMode double-invokes effects (mount -> cleanup -> mount), so the wrapper
// constructs a second emoji-mart Picker against the same container. It stays at one
// element only because emoji-mart clears the container (`ref.innerHTML = ''`) before
// appending. If that ever changes upstream, this catches the duplicated picker.
const { container } = render(
<StrictMode>
<Picker data={data} />
</StrictMode>,
);

await getRenderedPicker(container);
});

it('updates the existing instance on re-render instead of remounting it', async () => {
const { container, rerender } = render(<Picker data={data} theme='light' />);

const element = await getRenderedPicker(container);
expect(element?.shadowRoot?.querySelector('#root')).toHaveAttribute(
'data-theme',
'light',
);

rerender(<Picker data={data} theme='dark' />);

await waitFor(() =>
expect(element?.shadowRoot?.querySelector('#root')).toHaveAttribute(
'data-theme',
'dark',
),
);
// the same custom element instance was updated in place, not torn down and rebuilt
expect(pickerElements(container)).toHaveLength(1);
expect(container.querySelector('em-emoji-picker')).toBe(element);
});

it('removes the picker element on unmount', async () => {
const { container, unmount } = render(<Picker data={data} />);
await getRenderedPicker(container);
unmount();
expect(pickerElements(container)).toHaveLength(0);
});
});
14 changes: 0 additions & 14 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -605,16 +605,6 @@ __metadata:
languageName: node
linkType: hard

"@emoji-mart/react@npm:^1.1.1":
version: 1.1.1
resolution: "@emoji-mart/react@npm:1.1.1"
peerDependencies:
emoji-mart: ^5.2
react: ^16.8 || ^17 || ^18
checksum: 10c0/88a9c8c24bbc5695f0ed2458734c9982c965a16db1999bc731c7cce77f9bf228f1871e899744f9a3f9fdd36a11db7ad6c0e049d710cb91c66c69a2cd4d2ee40a
languageName: node
linkType: hard

"@eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1":
version: 4.9.1
resolution: "@eslint-community/eslint-utils@npm:4.9.1"
Expand Down Expand Up @@ -9487,7 +9477,6 @@ __metadata:
"@commitlint/cli": "npm:^21.0.1"
"@commitlint/config-conventional": "npm:^21.0.1"
"@emoji-mart/data": "npm:^1.2.1"
"@emoji-mart/react": "npm:^1.1.1"
"@eslint/js": "npm:^9.39.4"
"@floating-ui/react": "npm:^0.27.19"
"@react-aria/focus": "npm:^3.22.0"
Expand Down Expand Up @@ -9564,7 +9553,6 @@ __metadata:
peerDependencies:
"@breezystack/lamejs": ^1.2.7
"@emoji-mart/data": ^1.1.0
"@emoji-mart/react": ^1.1.0
emoji-mart: ^5.4.0
modern-normalize: ^3.0.1
react: ^19.0.0 || ^18.0.0 || ^17.0.0
Expand All @@ -9586,8 +9574,6 @@ __metadata:
optional: true
"@emoji-mart/data":
optional: true
"@emoji-mart/react":
optional: true
emoji-mart:
optional: true
modern-normalize:
Expand Down
Loading