Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions examples/vite/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
SegmentedReactionsList,
} from './CustomMessageUi';
import { ConfigurableMessageActions } from './CustomMessageActions';
import { InlineEditableMessage } from './InlineEditMessage';
import { SidebarToggle } from './Sidebar/SidebarToggle.tsx';
import { CommandModeAttachmentSelector } from './CommandModeAttachmentSelector.tsx';

Expand Down Expand Up @@ -424,6 +425,7 @@ const App = () => {
HeaderStartContent: SidebarToggle,
MessageActions: ConfigurableMessageActions,
AttachmentSelector: CommandModeAttachmentSelector,
Message: InlineEditableMessage,
...messageUiOverrides,
}}
>
Expand Down
2 changes: 2 additions & 0 deletions examples/vite/src/AppSettings/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type MessageActionsSettingsState = {
delete: {
enableOptionConfiguration: boolean;
};
inlineEdit: boolean;
markOwnUnread: boolean;
viewMessageInfo: boolean;
};
Expand Down Expand Up @@ -121,6 +122,7 @@ const defaultAppSettingsState: AppSettingsState = {
delete: {
enableOptionConfiguration: false,
},
inlineEdit: false,
markOwnUnread: false,
viewMessageInfo: false,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,33 @@ export const MessageActionsTab = ({ close }: MessageActionsTabProps) => {
title='Show JSON viewer action in the message actions menu'
/>
</div>

<div className='app__settings-modal__field'>
<div className='app__settings-modal__field-label'>
Enable inline message editing
</div>
<SwitchField
checked={customMessageActions.inlineEdit}
id='inline-edit-message-switch'
onChange={(event) =>
appSettingsStore.partialNext({
messageActions: {
...messageActions,
customMessageActions: {
...customMessageActions,
inlineEdit: event.target.checked,
},
},
})
}
title='Add an "Edit inline" action that swaps the message bubble for a MessageComposer in place'
/>
<div className='app__settings-modal__field-comment'>
Adds an <strong>&ldquo;Edit inline&rdquo;</strong> action that replaces the
message with a <code>MessageComposer</code> scoped to that message via
<code> MessageComposerControllerProvider</code>.
</div>
</div>
</SettingsTabBody>
</div>
);
Expand Down
22 changes: 22 additions & 0 deletions examples/vite/src/InlineEditMessage/InlineEditMessage.scss
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);
}
}
183 changes: 183 additions & 0 deletions examples/vite/src/InlineEditMessage/InlineEditMessage.tsx
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>
);
};
1 change: 1 addition & 0 deletions examples/vite/src/InlineEditMessage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { InlineEditableMessage } from './InlineEditMessage';
1 change: 1 addition & 0 deletions examples/vite/src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
@import url('./AppSettings/AppSettings.scss') layer(stream-app-overrides);
@import url('./CustomMessageActions/CustomMessageActions.scss')
layer(stream-app-overrides);
@import url('./InlineEditMessage/InlineEditMessage.scss') layer(stream-app-overrides);

Copy link
Copy Markdown

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-notation rule. This adds a Stylelint error. Remove the url() wrapper.

Proposed fix
-@import url('./InlineEditMessage/InlineEditMessage.scss') layer(stream-app-overrides);
+@import './InlineEditMessage/InlineEditMessage.scss' layer(stream-app-overrides);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@import url('./InlineEditMessage/InlineEditMessage.scss') layer(stream-app-overrides);
`@import` './InlineEditMessage/InlineEditMessage.scss' layer(stream-app-overrides);
🧰 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/vite/src/index.scss` at line 12, Update the SCSS import in the
stylesheet to use the configured direct string import notation by removing the
url() wrapper, while preserving the existing path and
layer(stream-app-overrides) declaration.

Source: Linters/SAST tools

@import url('./SystemNotification/SystemNotification.scss') layer(stream-app-overrides);
@import url('./AccessibilityNavigation/ReturnToSkipNavigation.scss')
layer(stream-app-overrides);
Expand Down
41 changes: 37 additions & 4 deletions src/components/MessageComposer/MessageComposer.tsx
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';
Expand All @@ -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;
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 MessageComposerProps API. Add preventClearingOnUnmount and its unmount semantics to the affected MessageComposer props guide; the inline comment alone is insufficient.

As per coding guidelines, public API changes must update inline docs and affected guide pages.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/MessageComposer/MessageComposer.tsx` around lines 82 - 85,
Update the affected MessageComposer props guide to document the public
preventClearingOnUnmount prop, including that setting it to true prevents
clearing the established MessageComposerController state when the component
unmounts. Keep the existing inline API documentation unchanged.

Source: Coding guidelines

};

const MessageComposerProvider = (props: PropsWithChildren<MessageComposerProps>) => {
Expand All @@ -99,9 +126,15 @@ const MessageComposerProvider = (props: PropsWithChildren<MessageComposerProps>)
// for a disconnected channel
if (messageComposer.channel.disconnected) return;
Comment thread
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());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
[messageComposer],
[messageComposer, props.preventClearingOnUnmount],
Comment on lines +133 to +137

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
fi

Repository: 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 || true

Repository: GetStream/stream-chat-react

Length of output: 29499


Avoid running unmount cleanup while re-running this effect.

props.preventClearingOnUnmount is a dependency, so changing it from false to true runs the existing cleanup before the new effect. That cleanup can call createDraft() and then clear() while the component is still mounted, dropping the current composer state. Key this only on messageComposer and read the latest prop from a ref.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/MessageComposer/MessageComposer.tsx` around lines 108 - 112,
Update the effect cleanup around messageComposer so changes to
props.preventClearingOnUnmount do not trigger cleanup while the component
remains mounted. Store the latest prop value in a ref and read that ref inside
the cleanup, while limiting the effect dependency array to messageComposer;
preserve the existing unmount guard and promise.finally clear behavior.

);

useEffect(() => {
Expand Down
Loading