-
Notifications
You must be signed in to change notification settings - Fork 279
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
1 parent
19486fa
commit 17a1160
Showing
31 changed files
with
436 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
Oops, something went wrong.