Skip to content

Commit 17a1160

Browse files
feat: new MessageActions component (#2543)
## The Problem(s) `MessageActions`/`MessageOptions`/`MessageActionsBox` are a poorly designed set of components which we were trying to patch over the time with things like `CustomMessageActionsList` but with each new patchy addition, the component set got heavier and worse to navigate in when it came to customization. - `messageActions` prop on `MessageList` and `VirtualizedMessageList` components allow for key/handler customization: ```tsx { "Custom Action": () => doThing() } ``` Solution like this falls face first when integrators need to access `MessageContext` or use translations for different languages (the key is used as button text). - Solution to patch those shortcomings was to introduce `CustomMessageActionsList` which allows to render custom buttons within actions dropdown but... when buttons are rendered conditionally, the "..." is still rendered and upon clicking on it, it opens up an empty actions dropdown. This solution also does not allow to adjust "quick actions" (like reply or react). ## Steps Taken 1. pre-define default set of actions with buttons as components with own logic and click handlers, separate into "quick" and "dropdown" types 2. define default filter which takes care of user capabilities and whether the actions are allowed within reply type of a message (and some other stuff based on message type or status) 3. allow integrators to override the default set and to override the default filter function, alow reusing defaults too ``` access action set -> filter action set based on filter function criteria -> separate into quick and dropdown -> render ``` ## From Integrator's POV ```tsx import { Channel, } from 'stream-chat-react'; import { MessageActions, defaultMessageActionSet, DefaultDropdownActionButton, } from 'stream-chat-react/experimental'; const CustomMessageActions = () => { const customFilter = () => { /*...*/ }; return ( <MessageActions // though not recommended, it's completely possible to disable default filter... disableBaseMessageActionSetFilter messageActionSet={[ ...defaultMessageActionSet, { type: 'myCustomTypeDropdown', placement: 'dropdown', // we can enforce non-null return type (at least through TS) Component: () => <DefaultDropdownActionButton>🚀 Custom</DefaultDropdownActionButton>, }, { type: 'myCustomTypeQuick', placement: 'quick', Component: () => <button>a</button>, }, // ...and apply custom filter here with access to CustomMessageActions scope (contexts + other hooks) ].filter(customFilter)} /> ); }; <Channel MessageActions={CustomMessageActions}>...</Channel>; ``` ![image](https://github.com/user-attachments/assets/ba75ee2c-50f8-45c5-8a1e-bb5e063d9e80) ## Next Steps - [x] review and release - [ ] allow overriding certain components within default `MessageActions_UNSTABLE` - [ ] tests
1 parent 19486fa commit 17a1160

31 files changed

+436
-18
lines changed

package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,18 @@
5050
},
5151
"default": "./dist/plugins/encoders/mp3.js"
5252
},
53+
"./experimental": {
54+
"types": "./dist/experimental/index.d.ts",
55+
"node": {
56+
"require": "./dist/experimental/index.node.cjs",
57+
"import": "./dist/experimental/index.js"
58+
},
59+
"browser": {
60+
"require": "./dist/experimental/index.browser.cjs",
61+
"import": "./dist/experimental/index.js"
62+
},
63+
"default": "./dist/experimental/index.js"
64+
},
5365
"./dist/css/*": {
5466
"default": "./dist/css/*"
5567
},
@@ -70,6 +82,9 @@
7082
],
7183
"mp3-encoder": [
7284
"./dist/plugins/encoders/mp3.d.ts"
85+
],
86+
"experimental": [
87+
"./dist/experimental/index.d.ts"
7388
]
7489
}
7590
},

scripts/bundle.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
1010
const sdkEntrypoint = resolve(__dirname, '../src/index.ts');
1111
const emojiEntrypoint = resolve(__dirname, '../src/plugins/Emojis/index.ts');
1212
const mp3EncoderEntrypoint = resolve(__dirname, '../src/plugins/encoders/mp3.ts');
13+
const experimentalEntrypoint = resolve(__dirname, '../src/experimental/index.ts');
1314
const outDir = resolve(__dirname, '../dist');
1415

1516
// Those dependencies are distributed as ES modules, and cannot be externalized
@@ -33,7 +34,7 @@ const external = deps.filter((dep) => !bundledDeps.includes(dep));
3334

3435
/** @type esbuild.BuildOptions */
3536
const cjsBundleConfig = {
36-
entryPoints: [sdkEntrypoint, emojiEntrypoint, mp3EncoderEntrypoint],
37+
entryPoints: [sdkEntrypoint, emojiEntrypoint, mp3EncoderEntrypoint, experimentalEntrypoint],
3738
bundle: true,
3839
format: 'cjs',
3940
target: 'es2020',

src/components/Channel/Channel.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ type ChannelPropsForwardedToComponentContext<
118118
| 'LinkPreviewList'
119119
| 'LoadingIndicator'
120120
| 'Message'
121+
| 'MessageActions'
121122
| 'MessageBouncePrompt'
122123
| 'MessageDeleted'
123124
| 'MessageListNotifications'
@@ -1226,6 +1227,7 @@ const ChannelInner = <
12261227
LinkPreviewList: props.LinkPreviewList,
12271228
LoadingIndicator: props.LoadingIndicator,
12281229
Message: props.Message,
1230+
MessageActions: props.MessageActions,
12291231
MessageBouncePrompt: props.MessageBouncePrompt,
12301232
MessageDeleted: props.MessageDeleted,
12311233
MessageListNotifications: props.MessageListNotifications,
@@ -1275,6 +1277,7 @@ const ChannelInner = <
12751277
props.LinkPreviewList,
12761278
props.LoadingIndicator,
12771279
props.Message,
1280+
props.MessageActions,
12781281
props.MessageBouncePrompt,
12791282
props.MessageDeleted,
12801283
props.MessageListNotifications,

src/components/ChatAutoComplete/ChatAutoComplete.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@ import { useComponentContext } from '../../context/ComponentContext';
1010
import type { CommandResponse, UserResponse } from 'stream-chat';
1111

1212
import type { TriggerSettings } from '../MessageInput/DefaultTriggerProvider';
13-
1413
import type { CustomTrigger, DefaultStreamChatGenerics, UnknownType } from '../../types/types';
15-
import { EmojiSearchIndex } from 'components/MessageInput';
14+
import type { EmojiSearchIndex } from '../MessageInput';
1615

1716
type ObjectUnion<T> = T[keyof T];
1817

src/components/Message/MessageOptions.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,7 @@ const UnMemoizedMessageOptions = <
9595
<ThreadIcon className='str-chat__message-action-icon' />
9696
</button>
9797
)}
98-
{shouldShowReactions && (
99-
<ReactionSelectorWithButton ReactionIcon={ReactionIcon} theme={theme} />
100-
)}
98+
{shouldShowReactions && <ReactionSelectorWithButton ReactionIcon={ReactionIcon} />}
10199
</div>
102100
);
103101
};

src/components/Message/MessageSimple.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,15 @@ const MessageSimpleWithContext = <
7070
Attachment = DefaultAttachment,
7171
Avatar = DefaultAvatar,
7272
EditMessageInput = DefaultEditMessageForm,
73+
MessageOptions = DefaultMessageOptions,
74+
// TODO: remove this "passthrough" in the next
75+
// major release and use the new default instead
76+
MessageActions = MessageOptions,
7377
MessageDeleted = DefaultMessageDeleted,
7478
MessageBouncePrompt = DefaultMessageBouncePrompt,
75-
MessageOptions = DefaultMessageOptions,
7679
MessageRepliesCountButton = DefaultMessageRepliesCountButton,
7780
MessageStatus = DefaultMessageStatus,
7881
MessageTimestamp = DefaultMessageTimestamp,
79-
8082
ReactionsList = DefaultReactionList,
8183
PinIndicator,
8284
} = useComponentContext<StreamChatGenerics>('MessageSimple');
@@ -171,7 +173,7 @@ const MessageSimpleWithContext = <
171173
onClick={handleClick}
172174
onKeyUp={handleClick}
173175
>
174-
<MessageOptions />
176+
<MessageActions />
175177
<div className='str-chat__message-reactions-host'>
176178
{hasReactions && <ReactionsList reverse />}
177179
</div>

src/components/Message/utils.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export const MESSAGE_ACTIONS = {
6565
};
6666

6767
export type MessageActionsArray<T extends string = string> = Array<
68-
'delete' | 'edit' | 'flag' | 'mute' | 'pin' | 'quote' | 'react' | 'reply' | T
68+
keyof typeof MESSAGE_ACTIONS | T
6969
>;
7070

7171
// @deprecated in favor of `channelCapabilities` - TODO: remove in next major release

src/components/MessageActions/MessageActions.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export type MessageActionsWrapperProps = {
147147
toggleOpen?: () => void;
148148
};
149149

150-
const MessageActionsWrapper = (props: PropsWithChildren<MessageActionsWrapperProps>) => {
150+
export const MessageActionsWrapper = (props: PropsWithChildren<MessageActionsWrapperProps>) => {
151151
const { children, customWrapperClass, inline, toggleOpen } = props;
152152

153153
const defaultWrapperClass = clsx(

src/components/Reactions/ReactionSelectorWithButton.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ import type { IconProps } from '../../types/types';
88
type ReactionSelectorWithButtonProps = {
99
/* Custom component rendering the icon used in a button invoking reactions selector for a given message. */
1010
ReactionIcon: React.ComponentType<IconProps>;
11-
/* Theme string to be added to CSS class names. */
12-
theme: string;
1311
};
1412

1513
/**
@@ -20,7 +18,6 @@ export const ReactionSelectorWithButton = <
2018
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
2119
>({
2220
ReactionIcon,
23-
theme,
2421
}: ReactionSelectorWithButtonProps) => {
2522
const { t } = useTranslationContext('ReactionSelectorWithButton');
2623
const { isMyMessage, message } = useMessageContext<StreamChatGenerics>('MessageOptions');
@@ -42,7 +39,7 @@ export const ReactionSelectorWithButton = <
4239
<button
4340
aria-expanded={dialogIsOpen}
4441
aria-label={t('aria/Open Reaction Selector')}
45-
className={`str-chat__message-${theme}__actions__action str-chat__message-${theme}__actions__action--reactions str-chat__message-reactions-button`}
42+
className='str-chat__message-reactions-button'
4643
data-testid='message-reaction-action'
4744
onClick={() => dialog?.toggle()}
4845
ref={buttonRef}

src/components/Threads/hooks/useThreadManagerState.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { useChatContext } from 'context';
21
import { ThreadManagerState } from 'stream-chat';
2+
3+
import { useChatContext } from '../../../context';
34
import { useStateStore } from '../../../store';
45

56
export const useThreadManagerState = <T extends readonly unknown[]>(

0 commit comments

Comments
 (0)