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
8 changes: 8 additions & 0 deletions ai-docs/ai-migration-v14-v15.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ const ReadOnlyActions = () => (
- **Intercept the outgoing request** → register a `sendMessageRequest` handler on `client.config` (also `updateMessageRequest` / `deleteMessageRequest` / `markReadRequest`). `channel.messageOperations` uses it instead of the default request. The `do*Request` props that did this in v14 are removed — see "Per-component request-handler props removed" below.
- **Transform the composed message** → register composition middleware on `messageComposer`.

### `MessageComposer` `preventClearingOnUnmount` prop → removed

`MessageComposer` no longer clears a composer supplied through `MessageComposerControllerProvider`
when it unmounts - that composer belongs to whoever supplied it - so the prop that opted out of the
clearing is gone. Drop it. If you relied on the unmount clearing a supplied composer, call `clear()`
on it yourself once your UI is done with it. The channel's and thread's own composers are still
cleared on unmount, after their draft is saved.

### `ChatContext.setActiveChannel` → removed

There is no `setActiveChannel` on `ChatContext`. Bind a channel by:
Expand Down
2 changes: 2 additions & 0 deletions examples/vite/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import {
SegmentedReactionsList,
} from './CustomMessageUi';
import { ConfigurableMessageActions } from './CustomMessageActions';
import { InlineEditableMessage } from './InlineEditMessage';
import { SidebarToggle } from './Sidebar/SidebarToggle.tsx';
import { CommandModeAttachmentSelector } from './CommandModeAttachmentSelector.tsx';
import { StreamDebugHandles } from './Debug';
Expand Down Expand Up @@ -654,6 +655,7 @@ const App = () => {
HeaderStartContent: SidebarToggle,
MessageActions: ConfigurableMessageActions,
AttachmentSelector: CommandModeAttachmentSelector,
MessageUI: InlineEditableMessage,
...messageUiOverrides,
}}
>
Expand Down
3 changes: 3 additions & 0 deletions examples/vite/src/AppSettings/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type CustomMessageActionToggles = {
* *off* so a newly shipped default action appears without a settings migration.
*/
disabledActionTypes: string[];
inlineEdit: boolean;
markOwnUnread: boolean;
viewMessageInfo: boolean;
};
Expand Down Expand Up @@ -191,6 +192,7 @@ const defaultAppSettingsState: AppSettingsState = {
enableOptionConfiguration: false,
},
disabledActionTypes: [],
inlineEdit: false,
markOwnUnread: false,
viewMessageInfo: false,
},
Expand All @@ -199,6 +201,7 @@ const defaultAppSettingsState: AppSettingsState = {
enableOptionConfiguration: false,
},
disabledActionTypes: [],
inlineEdit: false,
markOwnUnread: false,
viewMessageInfo: false,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ const CUSTOM_ACTION_ROWS: ActionRow[] = [
delete: { enableOptionConfiguration: value },
}),
},
{
comment: (
<>
Adds an <strong>&ldquo;Edit inline&rdquo;</strong> action that swaps the message
for a <code>MessageComposer</code> in place, supplied through{' '}
<code>MessageComposerControllerProvider</code>.
</>
),
id: 'inline-edit',
label: 'Edit inline',
read: (toggles) => toggles.inlineEdit,
write: (toggles, value) => ({ ...toggles, inlineEdit: value }),
},
{
comment: 'Mark own messages as unread too.',
id: 'mark-own-unread',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {

import { useAppSettingsSelector } from '../AppSettings';
import type { MessageActionSurface } from '../AppSettings';
import { InlineEditMessageAction } from '../InlineEditMessage';
import {
MessageInfoPromptDialog,
messageInfoPromptDialogId,
Expand Down Expand Up @@ -286,7 +287,11 @@ const CustomViewMessageInfoAction = () => {
);
};

type SupportedCustomMessageActionType = 'delete' | 'markOwnUnread' | 'viewMessageInfo';
type SupportedCustomMessageActionType =
| 'delete'
| 'editInline'
| 'markOwnUnread'
| 'viewMessageInfo';

type CustomMessageActionOverrideSpec = {
actionSetItem: MessageActionSetItem;
Expand Down Expand Up @@ -379,6 +384,16 @@ export const ConfigurableMessageActions = (
},
mode: 'replace',
},
// Next to the built-in edit, so the two ways of editing read as alternatives.
editInline: {
actionSetItem: {
Component: InlineEditMessageAction,
placement: 'dropdown',
type: 'editInline',
},
insertBeforeType: 'edit',
mode: 'append',
},
markOwnUnread: {
actionSetItem: {
Component: CustomMarkOwnUnreadMessageAction,
Expand All @@ -404,6 +419,10 @@ export const ConfigurableMessageActions = (
...actionOverrides.delete,
enabled: customDeleteEnabled,
},
{
...actionOverrides.editInline,
enabled: surfaceActions.inlineEdit,
},
{
...actionOverrides.markOwnUnread,
enabled: surfaceActions.markOwnUnread,
Expand All @@ -430,6 +449,7 @@ export const ConfigurableMessageActions = (
}, [
customDeleteEnabled,
surfaceActions.disabledActionTypes,
surfaceActions.inlineEdit,
surfaceActions.markOwnUnread,
surfaceActions.viewMessageInfo,
props.messageActionSet,
Expand Down
4 changes: 4 additions & 0 deletions examples/vite/src/InlineEditMessage/InlineEditMessage.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.app__inline-edit-message {
padding: 0.5rem 0;
width: 100%;
}
145 changes: 145 additions & 0 deletions examples/vite/src/InlineEditMessage/InlineEditMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { MessageComposer as MessageComposerController } from 'stream-chat';
import type { LocalMessage, MessageComposerState } from 'stream-chat';
import {
asDynamicKey,
ContextMenuButton,
MessageUI as DefaultMessageUI,
IconEdit,
MessageComposer,
MessageComposerControllerProvider,
type MessageUIComponentProps,
useChatContext,
useContextMenuContext,
useMessageContext,
useStateStore,
useThreadContext,
useTranslationContext,
} from 'stream-chat-react';

import { useAppSettingsSelector } from '../AppSettings';
import type { MessageActionSurface } from '../AppSettings';

type InlineEditContextValue = {
startEditing: (message: LocalMessage) => void;
};

// Lets the "Edit inline" action, rendered deep in the message's actions menu, start editing the
// row it belongs to.
const InlineEditContext = createContext<InlineEditContextValue | undefined>(undefined);

/**
* The "Edit inline" message action. `ConfigurableMessageActions` adds it to the action set when
* the setting is on; it only works inside an `InlineEditableMessage`, which provides the context.
*/
export const InlineEditMessageAction = () => {
const inlineEdit = useContext(InlineEditContext);
const { closeMenu } = useContextMenuContext();
const { message } = useMessageContext();
const { t } = useTranslationContext();

if (!inlineEdit) return null;

return (
<ContextMenuButton
aria-label={t(
asDynamicKey('viteExample.inlineEdit.action.ariaLabel'),
'Edit message inline',
)}
className='str-chat__message-actions-list-item-button'
Icon={IconEdit}
onClick={() => {
inlineEdit.startEditing(message);
closeMenu();
}}
>
{t(asDynamicKey('viteExample.inlineEdit.action.label'), 'Edit inline')}
</ContextMenuButton>
);
};

const editingSelector = (state: MessageComposerState) => ({
editing: state.editedMessage != null,
});

/**
* Swaps a message for a `MessageComposer` in place, editing it through a composer of its own
* supplied with `MessageComposerControllerProvider` - so the channel's own composer, and whatever
* the user was typing into it, are left alone. That composer is kept in the client's composer cache
* under the message's tag, so an unfinished edit outlives this row.
*/
export const InlineEditableMessage = (props: MessageUIComponentProps) => {
const { client } = useChatContext();
const { message } = useMessageContext();
const surface: MessageActionSurface = useThreadContext() ? 'thread' : 'channel';
const { customMessageActions } = useAppSettingsSelector(
(state) => state.messageActions,
);
const inlineEditEnabled = customMessageActions[surface].inlineEdit;

// Only looks - a row nobody edits creates nothing. A composer found here holding an edit is one
// left unfinished while this row was unmounted, so the editor comes straight back.
const [editingComposer, setEditingComposer] = useState(() =>
client.messageComposerCache.peek(MessageComposerController.constructTag(message)),
);

const { editing } = useStateStore(editingComposer?.state, editingSelector) ?? {
editing: false,
};

// Entering edit mode is where the composer is created if missing, and stored at once.
const startEditing = useCallback(
(messageToEdit: LocalMessage) => {
const tag = MessageComposerController.constructTag(messageToEdit);
const composer =
client.messageComposerCache.peek(tag) ??
// Drafts off: an edit is not a draft.
new MessageComposerController({
client,
compositionContext: messageToEdit,
config: { drafts: { enabled: false } },
});
client.messageComposerCache.add(tag, composer);
composer.initState({ composition: messageToEdit });
setEditingComposer(composer);
},
[client],
);

// Turning the setting off mid-edit abandons the edit, rather than leaving the message stuck as a
// composer with no way back.
useEffect(() => {
if (!inlineEditEnabled && editing) editingComposer?.clear();
}, [editing, editingComposer, inlineEditEnabled]);

const inlineEditContextValue = useMemo(() => ({ startEditing }), [startEditing]);

if (!inlineEditEnabled) return <DefaultMessageUI {...props} />;

if (editing && editingComposer) {
return (
<MessageComposerControllerProvider messageComposerController={editingComposer}>
<div className='app__inline-edit-message'>
{/* The composer outlives this editor: leaving the channel, or scrolling the row away in a
virtualized message list, unmounts the editor, and `MessageComposer` never clears a
supplied composer on unmount - so the unfinished edit is still there on return.
Cancelling is the ✕ on the edit preview, which clears it on purpose. */}
<MessageComposer />
</div>
</MessageComposerControllerProvider>
);
}

return (
<InlineEditContext.Provider value={inlineEditContextValue}>
<DefaultMessageUI {...props} />
</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, InlineEditMessageAction } 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);
@import url('./SystemNotification/SystemNotification.scss') layer(stream-app-overrides);
@import url('./AccessibilityNavigation/ReturnToSkipNavigation.scss')
layer(stream-app-overrides);
Expand Down
16 changes: 13 additions & 3 deletions src/components/MessageComposer/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import { useMessageComposerBindings } from './hooks/useMessageComposerBindings';
import type { ComponentContextValue } from '../../context/ComponentContext';
import { useComponentContext } from '../../context/ComponentContext';
import { MessageComposerContextProvider } from '../../context/MessageComposerContext';
import { DialogManagerProvider } from '../../context';
import {
DialogManagerProvider,
useMessageComposerControllerContext,
} from '../../context';
import { useStableId } from '../UtilityComponents/useStableId';

import type { LocalMessage } from 'stream-chat';
Expand Down Expand Up @@ -85,12 +88,19 @@ const MessageComposerProvider = (props: PropsWithChildren<MessageComposerProps>)
});

const messageComposer = useMessageComposerController();
const suppliedComposer = useMessageComposerControllerContext();

useEffect(
() => () => {
messageComposer.createDraft().finally(() => messageComposer.clear());
// `createDraft` already skips edits and composers with drafts disabled.
const draftSaved = messageComposer.createDraft().catch(console.error);
// Only the thread's or channel's own composer is emptied here. One supplied through
// `MessageComposerControllerProvider` belongs to whoever supplied it, and so does deciding
// when to clear it - clearing it on unmount would discard an edit the owner still holds (visible in Strict Mode).
if (messageComposer === suppliedComposer) return;
draftSaved.finally(() => messageComposer.clear());
},
[messageComposer],
[messageComposer, suppliedComposer],
);

useEffect(() => {
Expand Down
8 changes: 6 additions & 2 deletions src/components/MessageComposer/MessageComposerActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
useMessageComposerContext,
} from '../../context';
import { useAIState } from '../AIStateIndicator';
import { useMessageComposerController, useMessageContentIsEmpty } from './hooks';
import {
useMessageComposerController,
useMessageComposerSubmitFn,
useMessageContentIsEmpty,
} from './hooks';
import { AudioRecordingButtonWithNotification } from '../MediaRecorder/AudioRecorder/AudioRecordingButtonWithNotification';
import { useIsCooldownActive } from './hooks/useIsCooldownActive';
import { AIStates } from 'stream-chat';
Expand Down Expand Up @@ -67,7 +71,7 @@ export const MessageComposerActions = () => {
: StopAIGenerationButtonOverride;

const { recordingController } = useMessageComposerContext();
const submitMessageFn = editedMessage ? messageComposer.update : messageComposer.send;
const submitMessageFn = useMessageComposerSubmitFn();
const isCooldownActive = useIsCooldownActive();

const { aiState } = useAIState(channel);
Expand Down
Loading
Loading