Skip to content

Commit 256d862

Browse files
authored
feat: redesign MessageActions (#2951)
BREAKING CHANGE: order of actions in MessageActions has changed BREAKING CHANGE: removed CSS variables --str-chat__message-bounce-* BREAKING CHANGE: remove RemindMeActionButton and RemindMeActionButtonProps
1 parent 3af135f commit 256d862

56 files changed

Lines changed: 1043 additions & 242 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

examples/vite/src/index.scss

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
// v3 CSS import
77
@import url('stream-chat-react/dist/css/index.css') layer(stream-new);
8-
@import url('stream-chat-react/dist/css/emojis.css') layer(stream-new);
98
@import url('./AppSettings/AppSettings.scss') layer(stream-app-overrides);
109

1110
:root {

examples/vite/src/stream-imports-layout.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
@use 'stream-chat-react/dist/scss/v2/Location/Location-layout';
2929
//@use 'stream-chat-react/dist/scss/v2/Message/Message-layout';
3030
//@use 'stream-chat-react/dist/scss/v2/MessageActionsBox/MessageActionsBox-layout';
31-
@use 'stream-chat-react/dist/scss/v2/MessageBouncePrompt/MessageBouncePrompt-layout';
31+
//@use 'stream-chat-react/dist/scss/v2/MessageBouncePrompt/MessageBouncePrompt-layout';
3232
//@use 'stream-chat-react/dist/scss/v2/MessageInput/MessageInput-layout'; // X
3333
@use 'stream-chat-react/dist/scss/v2/MessageList/MessageList-layout';
3434
@use 'stream-chat-react/dist/scss/v2/MessageList/VirtualizedMessageList-layout';

examples/vite/src/stream-imports-theme.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
@use 'stream-chat-react/dist/scss/v2/Location/Location-theme';
2424
//@use 'stream-chat-react/dist/scss/v2/Message/Message-theme';
2525
//@use 'stream-chat-react/dist/scss/v2/MessageActionsBox/MessageActionsBox-theme';
26-
@use 'stream-chat-react/dist/scss/v2/MessageBouncePrompt/MessageBouncePrompt-theme';
26+
//@use 'stream-chat-react/dist/scss/v2/MessageBouncePrompt/MessageBouncePrompt-theme';
2727
@use 'stream-chat-react/dist/scss/v2/MessageList/MessageList-theme';
2828
@use 'stream-chat-react/dist/scss/v2/MessageList/VirtualizedMessageList-theme';
2929
// @use 'stream-chat-react/dist/scss/v2/MessageReactions/MessageReactions-theme';

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@
118118
"emoji-mart": "^5.4.0",
119119
"react": "^19.0.0 || ^18.0.0 || ^17.0.0",
120120
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0",
121-
"stream-chat": "^9.32.0"
121+
"stream-chat": "^9.35.0"
122122
},
123123
"peerDependenciesMeta": {
124124
"@breezystack/lamejs": {
@@ -212,7 +212,7 @@
212212
"react-dom": "^19.0.0",
213213
"sass": "^1.97.2",
214214
"semantic-release": "^25.0.2",
215-
"stream-chat": "^9.32.0",
215+
"stream-chat": "^9.35.0",
216216
"ts-jest": "^29.2.5",
217217
"typescript": "^5.4.5",
218218
"typescript-eslint": "^8.17.0",
@@ -221,7 +221,7 @@
221221
"scripts": {
222222
"clean": "rm -rf dist",
223223
"build": "yarn clean && concurrently './scripts/copy-css.sh' 'yarn build-translations' 'vite build' 'tsc --project tsconfig.lib.json' 'yarn build-styling'",
224-
"build-styling": "sass src/styling/index.scss dist/css/index.css && sass src/plugins/Emojis/styling/index.scss dist/css/emojis.css",
224+
"build-styling": "sass src/styling/index.scss dist/css/index.css",
225225
"build-translations": "i18next-cli extract",
226226
"coverage": "jest --collectCoverage && codecov",
227227
"lint": "yarn prettier --list-different && yarn eslint && yarn validate-translations",

src/components/Channel/Channel.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -967,15 +967,23 @@ const ChannelInner = (
967967
};
968968

969969
const retrySendMessage = async (localMessage: LocalMessage) => {
970+
/**
971+
* If type is not checked, and we for example send message.type === 'error',
972+
* then request fails with error: "message.type must be one of ['' regular system]".
973+
* For now, we re-send any other type to prevent breaking behavior.
974+
*/
975+
976+
const type = localMessage.type === 'error' ? 'regular' : localMessage.type;
970977
updateMessage({
971978
...localMessage,
972979
error: undefined,
973980
status: 'sending',
981+
type,
974982
});
975983

976984
await doSendMessage({
977985
localMessage,
978-
message: localMessageToNewMessagePayload(localMessage),
986+
message: localMessageToNewMessagePayload({ ...localMessage, type }),
979987
});
980988
};
981989

src/components/Dialog/base/ContextMenu.tsx

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable @typescript-eslint/no-unused-vars */
12
import React, {
23
type ComponentProps,
34
type ComponentType,
@@ -10,6 +11,9 @@ import React, {
1011
} from 'react';
1112
import clsx from 'clsx';
1213
import { IconChevronLeft } from '../../Icons';
14+
import { useDialogIsOpen } from '../hooks';
15+
import type { DialogAnchorProps } from '../service/DialogAnchor';
16+
import { DialogAnchor } from '../service/DialogAnchor';
1317

1418
export const ContextMenuBackButton = ({
1519
children,
@@ -96,7 +100,7 @@ type ContextMenuLevel = {
96100
menuClassName?: string;
97101
};
98102

99-
export type ContextMenuProps = Omit<ComponentProps<'div'>, 'children'> & {
103+
type ContextMenuBaseProps = Omit<ComponentProps<'div'>, 'children'> & {
100104
backLabel?: ReactNode;
101105
items: ContextMenuItemComponent[];
102106
Header?: ContextMenuHeaderComponent;
@@ -106,7 +110,24 @@ export type ContextMenuProps = Omit<ComponentProps<'div'>, 'children'> & {
106110
onMenuLevelChange?: (level: number) => void;
107111
};
108112

109-
export const ContextMenu = ({
113+
/** When provided, ContextMenu renders inside DialogAnchor and wires menu level for submenu alignment. */
114+
type ContextMenuAnchorProps = Partial<
115+
Pick<
116+
DialogAnchorProps,
117+
| 'id'
118+
| 'dialogManagerId'
119+
| 'placement'
120+
| 'referenceElement'
121+
| 'tabIndex'
122+
| 'trapFocus'
123+
| 'allowFlip'
124+
| 'focus'
125+
>
126+
>;
127+
128+
export type ContextMenuProps = ContextMenuBaseProps & ContextMenuAnchorProps;
129+
130+
function ContextMenuContent({
110131
backLabel = 'Back',
111132
className,
112133
Header,
@@ -116,7 +137,7 @@ export const ContextMenu = ({
116137
onClose,
117138
onMenuLevelChange,
118139
...props
119-
}: ContextMenuProps) => {
140+
}: ContextMenuBaseProps) {
120141
const rootLevel = useMemo<ContextMenuLevel>(
121142
() => ({
122143
Header,
@@ -207,4 +228,65 @@ export const ContextMenu = ({
207228
</ContextMenuRoot>
208229
</ContextMenuContext.Provider>
209230
);
231+
}
232+
233+
export const ContextMenu = (props: ContextMenuProps) => {
234+
const {
235+
allowFlip,
236+
dialogManagerId,
237+
focus,
238+
id,
239+
placement,
240+
referenceElement,
241+
tabIndex,
242+
trapFocus,
243+
...menuProps
244+
} = props;
245+
246+
const isAnchored = id != null;
247+
248+
const [menuLevel, setMenuLevel] = useState(1);
249+
const open = useDialogIsOpen(id ?? '', dialogManagerId);
250+
251+
useEffect(() => {
252+
if (isAnchored && !open) setMenuLevel(1);
253+
}, [isAnchored, open]);
254+
255+
const content = (
256+
<ContextMenuContent
257+
{...menuProps}
258+
onMenuLevelChange={isAnchored ? setMenuLevel : menuProps.onMenuLevelChange}
259+
/>
260+
);
261+
262+
if (isAnchored) {
263+
const {
264+
backLabel: _b,
265+
Header: _h,
266+
items: _i,
267+
ItemsWrapper: _w,
268+
menuClassName: _m,
269+
onClose: _c,
270+
onMenuLevelChange: _l,
271+
...anchorDivProps
272+
} = menuProps;
273+
return (
274+
<DialogAnchor
275+
allowFlip={allowFlip}
276+
dialogManagerId={dialogManagerId}
277+
focus={focus}
278+
id={id}
279+
placement={placement}
280+
referenceElement={referenceElement}
281+
tabIndex={tabIndex}
282+
trapFocus={trapFocus}
283+
updateKey={menuLevel}
284+
{...anchorDivProps}
285+
>
286+
{content}
287+
</DialogAnchor>
288+
);
289+
}
290+
291+
return content;
210292
};

src/components/Dialog/base/ContextMenuButton.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,24 @@ const ContextMenuButtonWithSubmenu = ({
234234

235235
type ContextMenuButtonProps = BaseContextMenuButtonProps;
236236

237-
export const ContextMenuButton = (props: ContextMenuButtonProps) => (
238-
<BaseContextMenuButton {...props} />
239-
);
237+
export const ContextMenuButton = ({
238+
onBlur,
239+
onFocus,
240+
...props
241+
}: ContextMenuButtonProps) => {
242+
const [isFocused, setIsFocused] = useState(false);
243+
return (
244+
<BaseContextMenuButton
245+
{...props}
246+
aria-selected={isFocused ? 'true' : 'false'}
247+
onBlur={(e) => {
248+
setIsFocused(false);
249+
onBlur?.(e);
250+
}}
251+
onFocus={(e) => {
252+
setIsFocused(true);
253+
onFocus?.(e);
254+
}}
255+
/>
256+
);
257+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { type ComponentProps, type ComponentType, forwardRef } from 'react';
2+
import clsx from 'clsx';
3+
4+
export const Root = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function PromptRoot(
5+
{ children, className, ...props }: ComponentProps<'div'>,
6+
ref,
7+
) {
8+
return (
9+
<div {...props} className={clsx('str-chat__prompt-root', className)} ref={ref}>
10+
{children}
11+
</div>
12+
);
13+
});
14+
15+
export type PromptHeaderProps = ComponentProps<'div'> & {
16+
title?: string;
17+
description?: string;
18+
Icon?: ComponentType;
19+
};
20+
21+
export const Header = forwardRef<HTMLDivElement, PromptHeaderProps>(function PromptRoot(
22+
{ children, className, description, Icon, title, ...props },
23+
ref,
24+
) {
25+
return (
26+
<div {...props} className={clsx('str-chat__prompt-header', className)} ref={ref}>
27+
{title ? (
28+
<>
29+
{Icon && <Icon />}
30+
<div className='str-chat__prompt-header__copy'>
31+
<div className='str-chat__prompt-header__title'>{title}</div>
32+
{description && (
33+
<div className='str-chat__prompt-header__description'>{description}</div>
34+
)}
35+
</div>
36+
</>
37+
) : (
38+
children
39+
)}
40+
</div>
41+
);
42+
});
43+
44+
const Actions = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function PromptRoot(
45+
{ children, className, ...props },
46+
ref,
47+
) {
48+
return (
49+
<div {...props} className={clsx('str-chat__prompt-actions', className)} ref={ref}>
50+
{children}
51+
</div>
52+
);
53+
});
54+
55+
export const Prompt = {
56+
Actions,
57+
Header,
58+
Root,
59+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './Callout';
22
export * from './ContextMenuButton';
33
export * from './ContextMenu';
4+
export * from './Prompt';

src/components/Dialog/service/DialogAnchor.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import clsx from 'clsx';
22
import type { ComponentProps, PropsWithChildren } from 'react';
3-
import React, { useEffect, useState } from 'react';
3+
import React, { useEffect, useRef, useState } from 'react';
44
import { FocusScope } from '@react-aria/focus';
55
import { DialogPortalEntry } from './DialogPortal';
66
import { useDialog, useDialogIsOpen } from '../hooks';
@@ -29,22 +29,33 @@ export function useDialogAnchor<T extends HTMLElement>({
2929
placement,
3030
});
3131

32+
// Freeze reference when dialog opens so submenus (e.g. ContextMenu level 2+) stay aligned to the original anchor
33+
const frozenReferenceRef = useRef<HTMLElement | null>(null);
34+
if (open && referenceElement && !frozenReferenceRef.current) {
35+
frozenReferenceRef.current = referenceElement;
36+
}
37+
if (!open) {
38+
frozenReferenceRef.current = null;
39+
}
40+
const effectiveReference = open ? frozenReferenceRef.current : referenceElement;
41+
3242
useEffect(() => {
33-
refs.setReference(referenceElement);
34-
}, [referenceElement, refs]);
43+
refs.setReference(effectiveReference);
44+
}, [effectiveReference, refs]);
3545

3646
useEffect(() => {
3747
refs.setFloating(popperElement);
3848
}, [popperElement, refs]);
3949

4050
useEffect(() => {
41-
if (open && popperElement) {
51+
if (open && popperElement && effectiveReference) {
52+
// Re-run when reference becomes available (e.g. after ref is set) or when updateKey changes (e.g. submenu open)
4253
// Since the popper's reference element might not be (and usually is not) visible
4354
// all the time, it's safer to force popper update before showing it.
4455
// update is non-null only if popperElement is non-null
4556
update?.();
4657
}
47-
}, [open, placement, popperElement, update, updateKey]);
58+
}, [open, placement, popperElement, update, updateKey, effectiveReference]);
4859

4960
if (popperElement && !open) {
5061
setPopperElement(null);
@@ -83,6 +94,7 @@ export const DialogAnchor = ({
8394
}: DialogAnchorProps) => {
8495
const dialog = useDialog({ dialogManagerId, id });
8596
const open = useDialogIsOpen(id, dialogManagerId);
97+
8698
const { setPopperElement, styles } = useDialogAnchor<HTMLDivElement>({
8799
allowFlip,
88100
open,

0 commit comments

Comments
 (0)