-
Notifications
You must be signed in to change notification settings - Fork 299
feat(MessageComposer): introduce context for custom composers #3249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e16e80b
47ec82d
7e33e04
5e88df9
b250b8f
e6b6fa7
931bf52
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| .app__inline-edit-message { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.5rem; | ||
| padding: 0.5rem 0; | ||
| width: 100%; | ||
| } | ||
|
|
||
| .app__inline-edit-message__cancel { | ||
| align-self: flex-end; | ||
| background: transparent; | ||
| border: 1px solid var(--str-chat__secondary-surface-color, #dbdde1); | ||
| border-radius: 999px; | ||
| color: var(--str-chat__text-color, inherit); | ||
| cursor: pointer; | ||
| font-size: 0.85rem; | ||
| padding: 0.25rem 0.75rem; | ||
|
|
||
| &:hover { | ||
| background: var(--str-chat__secondary-surface-color, #f7f7f8); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| import { | ||
| type ComponentProps, | ||
| createContext, | ||
| useCallback, | ||
| useContext, | ||
| useEffect, | ||
| useMemo, | ||
| useState, | ||
| } from 'react'; | ||
| import { MessageComposer as MessageComposerController } from 'stream-chat'; | ||
| import type { MessageComposerState } from 'stream-chat'; | ||
| import { useChannelStateContext } from 'stream-chat-react'; | ||
| import { | ||
| ContextMenuButton, | ||
| defaultMessageActionSet, | ||
| MessageUI as DefaultMessageUI, | ||
| IconEdit, | ||
| MessageActions, | ||
| type MessageActionSetItem, | ||
| MessageComposer, | ||
| MessageComposerControllerProvider, | ||
| type MessageUIComponentProps, | ||
| useChatContext, | ||
| useComponentContext, | ||
| useContextMenuContext, | ||
| useMessageContext, | ||
| useStateStore, | ||
| useTranslationContext, | ||
| WithComponents, | ||
| } from 'stream-chat-react'; | ||
|
|
||
| import { useAppSettingsSelector } from '../AppSettings'; | ||
|
|
||
| type InlineEditContextValue = { | ||
| isEditing: boolean; | ||
| startEditing: () => void; | ||
| stopEditing: () => void; | ||
| }; | ||
|
|
||
| const InlineEditContext = createContext<InlineEditContextValue | undefined>(undefined); | ||
|
|
||
| const useInlineEditContext = () => { | ||
| const value = useContext(InlineEditContext); | ||
| if (!value) { | ||
| throw new Error('useInlineEditContext must be used within an InlineEditableMessage'); | ||
| } | ||
| return value; | ||
| }; | ||
|
|
||
| const InlineEditAction = () => { | ||
| const { closeMenu } = useContextMenuContext(); | ||
| const { startEditing } = useInlineEditContext(); | ||
| const { t } = useTranslationContext(); | ||
|
|
||
| return ( | ||
| <ContextMenuButton | ||
| aria-label={t('aria/Edit Message Inline')} | ||
| className='str-chat__message-actions-list-item-button' | ||
| Icon={IconEdit} | ||
| onClick={() => { | ||
| startEditing(); | ||
| closeMenu(); | ||
| }} | ||
| > | ||
| {t('Edit inline')} | ||
| </ContextMenuButton> | ||
| ); | ||
| }; | ||
|
|
||
| const inlineEditActionSetItem: MessageActionSetItem = { | ||
| Component: InlineEditAction, | ||
| placement: 'dropdown', | ||
| type: 'editInline', | ||
| }; | ||
|
|
||
| const insertInlineEditAction = ( | ||
| actionSet: MessageActionSetItem[], | ||
| ): MessageActionSetItem[] => { | ||
| const editIndex = actionSet.findIndex((item) => 'type' in item && item.type === 'edit'); | ||
|
|
||
| if (editIndex < 0) return [...actionSet, inlineEditActionSetItem]; | ||
|
|
||
| return [ | ||
| ...actionSet.slice(0, editIndex), | ||
| inlineEditActionSetItem, | ||
| ...actionSet.slice(editIndex), | ||
| ]; | ||
| }; | ||
|
|
||
| const InlineEditComposer = ({ onExit }: { onExit: () => void }) => { | ||
| const { t } = useTranslationContext(); | ||
|
|
||
| return ( | ||
| <div className='app__inline-edit-message'> | ||
| <MessageComposer preventClearingOnUnmount /> | ||
| <button className='app__inline-edit-message__cancel' onClick={onExit} type='button'> | ||
| {t('Cancel')} | ||
| </button> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| const selector = (state: MessageComposerState) => ({ | ||
| editing: state.editedMessage != null, | ||
| }); | ||
|
|
||
| export const InlineEditableMessage = (props: MessageUIComponentProps) => { | ||
| const { client } = useChatContext(); | ||
| const { channel } = useChannelStateContext(); | ||
| const { message } = useMessageContext(); | ||
| const inlineEditEnabled = useAppSettingsSelector( | ||
| (state) => state.messageActions.customMessageActions, | ||
| ).inlineEdit; | ||
|
|
||
| const { MessageActions: OuterMessageActions = MessageActions } = useComponentContext(); | ||
|
|
||
| const [editingComposer] = useState( | ||
| () => | ||
| new MessageComposerController({ | ||
| compositionContext: channel, | ||
| client, | ||
| config: { drafts: { enabled: false } }, | ||
| }), | ||
| ); | ||
|
|
||
| const { editing } = useStateStore(editingComposer.state, selector); | ||
|
|
||
| // If the setting is turned off mid-edit, abandon the in-progress edit so the | ||
| // message doesn't stay stuck in composer view with no way to submit it. | ||
| useEffect(() => { | ||
| if (!inlineEditEnabled && editing) editingComposer.clear(); | ||
| }, [editing, editingComposer, inlineEditEnabled]); | ||
|
|
||
| const startEditing = useCallback(() => { | ||
| editingComposer.initState({ composition: message }); | ||
| }, [editingComposer, message]); | ||
| const stopEditing = useCallback(() => { | ||
| editingComposer.clear(); | ||
| }, [editingComposer]); | ||
|
|
||
| const contextValue = useMemo<InlineEditContextValue>( | ||
| () => ({ isEditing: editing, startEditing, stopEditing }), | ||
| [editing, startEditing, stopEditing], | ||
| ); | ||
|
|
||
| const MessageActionsWithInlineEdit = useMemo(() => { | ||
| const Component = (actionsProps: ComponentProps<typeof MessageActions>) => { | ||
| const messageActionSet = useMemo( | ||
| () => | ||
| insertInlineEditAction( | ||
| actionsProps.messageActionSet ?? defaultMessageActionSet, | ||
| ), | ||
| [actionsProps.messageActionSet], | ||
| ); | ||
|
|
||
| return ( | ||
| <OuterMessageActions {...actionsProps} messageActionSet={messageActionSet} /> | ||
| ); | ||
| }; | ||
| Component.displayName = 'MessageActionsWithInlineEdit'; | ||
| return Component; | ||
| }, [OuterMessageActions]); | ||
|
|
||
| if (!inlineEditEnabled) { | ||
| return <DefaultMessageUI {...props} />; | ||
| } | ||
|
|
||
| if (editing) { | ||
| return ( | ||
| <MessageComposerControllerProvider messageComposerController={editingComposer}> | ||
| <InlineEditComposer onExit={stopEditing} /> | ||
| </MessageComposerControllerProvider> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <InlineEditContext.Provider value={contextValue}> | ||
| <WithComponents overrides={{ MessageActions: MessageActionsWithInlineEdit }}> | ||
| <DefaultMessageUI {...props} /> | ||
| </WithComponents> | ||
| </InlineEditContext.Provider> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { InlineEditableMessage } from './InlineEditMessage'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| import type { PropsWithChildren } from 'react'; | ||
| import React, { useEffect } from 'react'; | ||
| import React, { useContext, useEffect } from 'react'; | ||
|
|
||
| import { MessageComposerUI as DefaultMessageComposerUI } from './MessageComposerUI'; | ||
| import { useMessageComposerController } from './hooks'; | ||
|
|
@@ -11,11 +11,34 @@ import { MessageComposerContextProvider } from '../../context/MessageComposerCon | |
| import { DialogManagerProvider } from '../../context'; | ||
| import { useStableId } from '../UtilityComponents/useStableId'; | ||
|
|
||
| import type { LocalMessage, Message, SendMessageOptions } from 'stream-chat'; | ||
| import type { | ||
| LocalMessage, | ||
| Message, | ||
| MessageComposer as MessageComposerController, | ||
| SendMessageOptions, | ||
| } from 'stream-chat'; | ||
|
|
||
| import type { CustomAudioRecordingConfig } from '../MediaRecorder'; | ||
| import { useRegisterDropHandlers } from './WithDragAndDropUpload'; | ||
|
|
||
| const MessageComposerControllerContext = React.createContext< | ||
| MessageComposerController | undefined | ||
| >(undefined); | ||
|
|
||
| export const MessageComposerControllerProvider = ({ | ||
| children, | ||
| messageComposerController, | ||
| }: PropsWithChildren<{ | ||
| messageComposerController?: MessageComposerController; | ||
| }>) => ( | ||
| <MessageComposerControllerContext.Provider value={messageComposerController}> | ||
| {children} | ||
| </MessageComposerControllerContext.Provider> | ||
| ); | ||
|
|
||
| export const useMessageComposerControllerContext = () => | ||
| useContext(MessageComposerControllerContext); | ||
|
|
||
| export type EmojiSearchIndexResult = { | ||
| id: string; | ||
| name: string; | ||
|
|
@@ -79,6 +102,10 @@ export type MessageComposerProps = { | |
| * ``` | ||
| */ | ||
| shouldSubmit?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => boolean; | ||
| /** | ||
| * When set to `true` disables clearing established state of the MessageComposerController upon component unmount. | ||
| */ | ||
| preventClearingOnUnmount?: boolean; | ||
|
Comment on lines
+105
to
+108
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Document the new public prop in the guide page. This changes the public As per coding guidelines, public API changes must update inline docs and affected guide pages. 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| }; | ||
|
|
||
| const MessageComposerProvider = (props: PropsWithChildren<MessageComposerProps>) => { | ||
|
|
@@ -99,9 +126,15 @@ const MessageComposerProvider = (props: PropsWithChildren<MessageComposerProps>) | |
| // for a disconnected channel | ||
| if (messageComposer.channel.disconnected) return; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| messageComposer.createDraft().finally(() => messageComposer.clear()); | ||
| const promise = messageComposer.config.drafts.enabled | ||
| ? messageComposer.createDraft().catch(console.error) | ||
| : Promise.resolve(); | ||
|
|
||
| if (props.preventClearingOnUnmount) return; | ||
|
|
||
| promise.finally(() => messageComposer.clear()); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }, | ||
| [messageComposer], | ||
| [messageComposer, props.preventClearingOnUnmount], | ||
|
Comment on lines
+133
to
+137
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Locate MessageComposer files"
fd -a 'MessageComposer\.tsx$' . || true
echo
echo "Relevant snippet and nearby effect code"
if [ -f src/components/MessageComposer/MessageComposer.tsx ]; then
wc -l src/components/MessageComposer/MessageComposer.tsx
sed -n '1,170p' src/components/MessageComposer/MessageComposer.tsx | cat -n
fiRepository: GetStream/stream-chat-react Length of output: 8005 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Inspect controller draft/create/clear definitions"
rg -n "createDraft|clear\\(|drafts|state" src/components/MessageComposer -S
echo
echo "Inspect controller hooks"
fd -a 'Hooks?|Controller|.*Controller.*|.*Controller.*' src/components/MessageComposer | sed 's#^`#/`#' | head -50
echo
fd -a 'Hooks?|.*Controller.*' src/components/MessageComposer | while read -r f; do
echo "--- $f"
wc -l "$f"
sed -n '1,260p' "$f" | cat -n
done
echo
echo "Check package React version type/imports are safe for useRef"
if [ -f package.json ]; then
node -e "const p=require('./package.json'); console.log(JSON.stringify({react:p.dependencies?.react ?? p.devDependencies?.react}, null, 2))"
fi
rg "react|react-dom" package.json yarn.lock 2>/dev/null | head -40 || trueRepository: GetStream/stream-chat-react Length of output: 29499 Avoid running unmount cleanup while re-running this effect.
🤖 Prompt for AI Agents |
||
| ); | ||
|
|
||
| useEffect(() => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Use the configured SCSS import notation.
Line 12 violates the
import-notationrule. This adds a Stylelint error. Remove theurl()wrapper.Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 Stylelint (17.14.0)
[error] 12-12: Expected "url('./InlineEditMessage/InlineEditMessage.scss')" to be "'./InlineEditMessage/InlineEditMessage.scss'" (import-notation)
(import-notation)
🤖 Prompt for AI Agents
Source: Linters/SAST tools