Skip to content

Commit

Permalink
feat: new MessageActions component (#2543)
Browse files Browse the repository at this point in the history
## 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
  • Loading branch information
arnautov-anton authored Oct 29, 2024
1 parent 19486fa commit 17a1160
Show file tree
Hide file tree
Showing 31 changed files with 436 additions and 18 deletions.
15 changes: 15 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@
},
"default": "./dist/plugins/encoders/mp3.js"
},
"./experimental": {
"types": "./dist/experimental/index.d.ts",
"node": {
"require": "./dist/experimental/index.node.cjs",
"import": "./dist/experimental/index.js"
},
"browser": {
"require": "./dist/experimental/index.browser.cjs",
"import": "./dist/experimental/index.js"
},
"default": "./dist/experimental/index.js"
},
"./dist/css/*": {
"default": "./dist/css/*"
},
Expand All @@ -70,6 +82,9 @@
],
"mp3-encoder": [
"./dist/plugins/encoders/mp3.d.ts"
],
"experimental": [
"./dist/experimental/index.d.ts"
]
}
},
Expand Down
3 changes: 2 additions & 1 deletion scripts/bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const sdkEntrypoint = resolve(__dirname, '../src/index.ts');
const emojiEntrypoint = resolve(__dirname, '../src/plugins/Emojis/index.ts');
const mp3EncoderEntrypoint = resolve(__dirname, '../src/plugins/encoders/mp3.ts');
const experimentalEntrypoint = resolve(__dirname, '../src/experimental/index.ts');
const outDir = resolve(__dirname, '../dist');

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

/** @type esbuild.BuildOptions */
const cjsBundleConfig = {
entryPoints: [sdkEntrypoint, emojiEntrypoint, mp3EncoderEntrypoint],
entryPoints: [sdkEntrypoint, emojiEntrypoint, mp3EncoderEntrypoint, experimentalEntrypoint],
bundle: true,
format: 'cjs',
target: 'es2020',
Expand Down
3 changes: 3 additions & 0 deletions src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ type ChannelPropsForwardedToComponentContext<
| 'LinkPreviewList'
| 'LoadingIndicator'
| 'Message'
| 'MessageActions'
| 'MessageBouncePrompt'
| 'MessageDeleted'
| 'MessageListNotifications'
Expand Down Expand Up @@ -1226,6 +1227,7 @@ const ChannelInner = <
LinkPreviewList: props.LinkPreviewList,
LoadingIndicator: props.LoadingIndicator,
Message: props.Message,
MessageActions: props.MessageActions,
MessageBouncePrompt: props.MessageBouncePrompt,
MessageDeleted: props.MessageDeleted,
MessageListNotifications: props.MessageListNotifications,
Expand Down Expand Up @@ -1275,6 +1277,7 @@ const ChannelInner = <
props.LinkPreviewList,
props.LoadingIndicator,
props.Message,
props.MessageActions,
props.MessageBouncePrompt,
props.MessageDeleted,
props.MessageListNotifications,
Expand Down
3 changes: 1 addition & 2 deletions src/components/ChatAutoComplete/ChatAutoComplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ import { useComponentContext } from '../../context/ComponentContext';
import type { CommandResponse, UserResponse } from 'stream-chat';

import type { TriggerSettings } from '../MessageInput/DefaultTriggerProvider';

import type { CustomTrigger, DefaultStreamChatGenerics, UnknownType } from '../../types/types';
import { EmojiSearchIndex } from 'components/MessageInput';
import type { EmojiSearchIndex } from '../MessageInput';

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

Expand Down
4 changes: 1 addition & 3 deletions src/components/Message/MessageOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,7 @@ const UnMemoizedMessageOptions = <
<ThreadIcon className='str-chat__message-action-icon' />
</button>
)}
{shouldShowReactions && (
<ReactionSelectorWithButton ReactionIcon={ReactionIcon} theme={theme} />
)}
{shouldShowReactions && <ReactionSelectorWithButton ReactionIcon={ReactionIcon} />}
</div>
);
};
Expand Down
8 changes: 5 additions & 3 deletions src/components/Message/MessageSimple.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,15 @@ const MessageSimpleWithContext = <
Attachment = DefaultAttachment,
Avatar = DefaultAvatar,
EditMessageInput = DefaultEditMessageForm,
MessageOptions = DefaultMessageOptions,
// TODO: remove this "passthrough" in the next
// major release and use the new default instead
MessageActions = MessageOptions,
MessageDeleted = DefaultMessageDeleted,
MessageBouncePrompt = DefaultMessageBouncePrompt,
MessageOptions = DefaultMessageOptions,
MessageRepliesCountButton = DefaultMessageRepliesCountButton,
MessageStatus = DefaultMessageStatus,
MessageTimestamp = DefaultMessageTimestamp,

ReactionsList = DefaultReactionList,
PinIndicator,
} = useComponentContext<StreamChatGenerics>('MessageSimple');
Expand Down Expand Up @@ -171,7 +173,7 @@ const MessageSimpleWithContext = <
onClick={handleClick}
onKeyUp={handleClick}
>
<MessageOptions />
<MessageActions />
<div className='str-chat__message-reactions-host'>
{hasReactions && <ReactionsList reverse />}
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/Message/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export const MESSAGE_ACTIONS = {
};

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

// @deprecated in favor of `channelCapabilities` - TODO: remove in next major release
Expand Down
2 changes: 1 addition & 1 deletion src/components/MessageActions/MessageActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export type MessageActionsWrapperProps = {
toggleOpen?: () => void;
};

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

const defaultWrapperClass = clsx(
Expand Down
5 changes: 1 addition & 4 deletions src/components/Reactions/ReactionSelectorWithButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import type { IconProps } from '../../types/types';
type ReactionSelectorWithButtonProps = {
/* Custom component rendering the icon used in a button invoking reactions selector for a given message. */
ReactionIcon: React.ComponentType<IconProps>;
/* Theme string to be added to CSS class names. */
theme: string;
};

/**
Expand All @@ -20,7 +18,6 @@ export const ReactionSelectorWithButton = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
>({
ReactionIcon,
theme,
}: ReactionSelectorWithButtonProps) => {
const { t } = useTranslationContext('ReactionSelectorWithButton');
const { isMyMessage, message } = useMessageContext<StreamChatGenerics>('MessageOptions');
Expand All @@ -42,7 +39,7 @@ export const ReactionSelectorWithButton = <
<button
aria-expanded={dialogIsOpen}
aria-label={t('aria/Open Reaction Selector')}
className={`str-chat__message-${theme}__actions__action str-chat__message-${theme}__actions__action--reactions str-chat__message-reactions-button`}
className='str-chat__message-reactions-button'
data-testid='message-reaction-action'
onClick={() => dialog?.toggle()}
ref={buttonRef}
Expand Down
3 changes: 2 additions & 1 deletion src/components/Threads/hooks/useThreadManagerState.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useChatContext } from 'context';
import { ThreadManagerState } from 'stream-chat';

import { useChatContext } from '../../../context';
import { useStateStore } from '../../../store';

export const useThreadManagerState = <T extends readonly unknown[]>(
Expand Down
8 changes: 7 additions & 1 deletion src/context/ComponentContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ export type ComponentContextValue<
LoadingIndicator?: React.ComponentType<LoadingIndicatorProps>;
/** Custom UI component to display a message in the standard `MessageList`, defaults to and accepts the same props as: [MessageSimple](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageSimple.tsx) */
Message?: React.ComponentType<MessageUIComponentProps<StreamChatGenerics>>;
/** Custom UI component for message actions popup, accepts no props, all the defaults are set within [MessageActions (unstable)](https://github.com/GetStream/stream-chat-react/blob/master/src/experimental/MessageActions/MessageActions.tsx) */
MessageActions?: React.ComponentType;
/** Custom UI component to display the contents of a bounced message modal. Usually it allows to retry, edit, or delete the message. Defaults to and accepts the same props as: [MessageBouncePrompt](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageBounce/MessageBouncePrompt.tsx) */
MessageBouncePrompt?: React.ComponentType<MessageBouncePromptProps>;
/** Custom UI component for a deleted message, defaults to and accepts same props as: [MessageDeleted](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageDeleted.tsx) */
Expand All @@ -111,7 +113,11 @@ export type ComponentContextValue<
MessageListNotifications?: React.ComponentType<MessageListNotificationsProps>;
/** Custom UI component to display a notification when scrolled up the list and new messages arrive, defaults to and accepts same props as [MessageNotification](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/MessageNotification.tsx) */
MessageNotification?: React.ComponentType<MessageNotificationProps>;
/** Custom UI component for message options popup, defaults to and accepts same props as: [MessageOptions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageOptions.tsx) */
/**
* Custom UI component for message options popup, defaults to and accepts same props as: [MessageOptions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageOptions.tsx)
*
* @deprecated Use MessageActions property instead.
*/
MessageOptions?: React.ComponentType<MessageOptionsProps<StreamChatGenerics>>;
/** Custom UI component to display message replies, defaults to and accepts same props as: [MessageRepliesCountButton](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageRepliesCountButton.tsx) */
MessageRepliesCountButton?: React.ComponentType<MessageRepliesCountButtonProps>;
Expand Down
120 changes: 120 additions & 0 deletions src/experimental/MessageActions/MessageActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/* eslint-disable sort-keys */
import clsx from 'clsx';
import React, { PropsWithChildren, useState } from 'react';

import { useChatContext, useMessageContext, useTranslationContext } from '../../context';
import { ActionsIcon } from '../../components/Message/icons';
import { DialogAnchor, useDialog, useDialogIsOpen } from '../../components/Dialog';
import { MessageActionsWrapper } from '../../components/MessageActions/MessageActions';
import { MESSAGE_ACTIONS } from '../../components';

import { useBaseMessageActionSetFilter, useSplitMessageActionSet } from './hooks';
import { defaultMessageActionSet } from './defaults';

export type MessageActionSetItem = {
Component: React.ComponentType;
placement: 'quick' | 'dropdown';
type:
| keyof typeof MESSAGE_ACTIONS
// eslint-disable-next-line @typescript-eslint/ban-types
| (string & {});
};

export type MessageActionsProps = {
disableBaseMessageActionSetFilter?: boolean;
messageActionSet?: MessageActionSetItem[];
};

// TODO: allow passing down customWrapperClass
/**
* A new actions component to replace current `MessageOptions` component.
* Exports from `stream-chat-react/experimental` __MIGHT__ change - use with caution
* and follow release notes in case you notice unexpected behavior.
*/
export const MessageActions = ({
disableBaseMessageActionSetFilter = false,
messageActionSet = defaultMessageActionSet,
}: MessageActionsProps) => {
const { theme } = useChatContext();
const { isMyMessage, message } = useMessageContext();
const { t } = useTranslationContext();
const [actionsBoxButtonElement, setActionsBoxButtonElement] = useState<HTMLButtonElement | null>(
null,
);

const filteredMessageActionSet = useBaseMessageActionSetFilter(
messageActionSet,
disableBaseMessageActionSetFilter,
);

const { dropdownActionSet, quickActionSet } = useSplitMessageActionSet(filteredMessageActionSet);

const dropdownDialogId = `message-actions--${message.id}`;
const reactionSelectorDialogId = `reaction-selector--${message.id}`;
const dialog = useDialog({ id: dropdownDialogId });
const dropdownDialogIsOpen = useDialogIsOpen(dropdownDialogId);
const reactionSelectorDialogIsOpen = useDialogIsOpen(reactionSelectorDialogId);

// do not render anything if total action count is zero
if (dropdownActionSet.length + quickActionSet.length === 0) {
return null;
}

return (
<div
className={clsx(`str-chat__message-${theme}__actions str-chat__message-options`, {
'str-chat__message-options--active': dropdownDialogIsOpen || reactionSelectorDialogIsOpen,
})}
>
{dropdownActionSet.length > 0 && (
<MessageActionsWrapper inline={false} toggleOpen={dialog?.toggle}>
<button
aria-expanded={dropdownDialogIsOpen}
aria-haspopup='true'
aria-label={t('aria/Open Message Actions Menu')}
className='str-chat__message-actions-box-button'
data-testid='message-actions-toggle-button'
ref={setActionsBoxButtonElement}
>
<ActionsIcon className='str-chat__message-action-icon' />
</button>

<DialogAnchor
id={dropdownDialogId}
placement={isMyMessage() ? 'top-end' : 'top-start'}
referenceElement={actionsBoxButtonElement}
trapFocus
>
<DropdownBox open={dropdownDialogIsOpen}>
{dropdownActionSet.map(({ Component: DropdownActionComponent, type }) => (
<DropdownActionComponent key={type} />
))}
</DropdownBox>
</DialogAnchor>
</MessageActionsWrapper>
)}
{quickActionSet.map(({ Component: QuickActionComponent, type }) => (
<QuickActionComponent key={type} />
))}
</div>
);
};

const DropdownBox = ({ children, open }: PropsWithChildren<{ open: boolean }>) => {
const { t } = useTranslationContext();
return (
<div
className={clsx('str-chat__message-actions-box', {
'str-chat__message-actions-box--open': open,
})}
>
<div
aria-label={t('aria/Message Options')}
className='str-chat__message-actions-list'
role='listbox'
>
{children}
</div>
</div>
);
};
Loading

0 comments on commit 17a1160

Please sign in to comment.