diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx
index 90f480a7fd..eb34c371dc 100644
--- a/examples/vite/src/App.tsx
+++ b/examples/vite/src/App.tsx
@@ -19,7 +19,6 @@ import {
Button,
Chat,
ChatView,
- createIcon,
DialogManagerProvider,
MessageReactions,
type NotificationListProps,
@@ -67,6 +66,8 @@ import {
getReactionsVariant,
getSystemMessageVariant,
} from './CustomMessageUi';
+import { ConfigurableMessageActions } from './CustomMessageActions';
+import { SidebarToggle } from './Sidebar/SidebarToggle.tsx';
init({ data });
@@ -176,36 +177,6 @@ const ConfigurableNotificationList = (props: NotificationListProps) => {
return ;
};
-const IconSidebar = createIcon(
- 'IconSidebar',
- ,
-);
-
-const SidebarToggle = () => {
- const { closeSidebar, openSidebar, sidebarOpen } = useSidebar();
- const { t } = useTranslationContext();
- return (
-
- );
-};
-
const language = new URLSearchParams(window.location.search).get('language');
const i18nInstance = language ? new Streami18n({ language: language as any }) : undefined;
@@ -405,6 +376,7 @@ const App = () => {
Search: CustomChannelSearch,
HeaderEndContent: SidebarToggle,
HeaderStartContent: SidebarToggle,
+ MessageActions: ConfigurableMessageActions,
...messageUiOverrides,
}}
>
diff --git a/examples/vite/src/AppSettings/AppSettings.scss b/examples/vite/src/AppSettings/AppSettings.scss
index 687a993b2e..c12ec05062 100644
--- a/examples/vite/src/AppSettings/AppSettings.scss
+++ b/examples/vite/src/AppSettings/AppSettings.scss
@@ -453,6 +453,18 @@
color: var(--text-primary);
}
+ .app__settings-modal__field .str-chat__form__switch-field {
+ border: 1px solid var(--border-core-default);
+ border-radius: 10px;
+ padding: 10px 12px;
+ }
+
+ .app__settings-modal__field-comment {
+ color: var(--text-secondary);
+ font-size: 13px;
+ line-height: 1.4;
+ }
+
.app__settings-modal__options-row {
display: flex;
gap: 10px;
diff --git a/examples/vite/src/AppSettings/AppSettings.tsx b/examples/vite/src/AppSettings/AppSettings.tsx
index aed0e9db34..e1f3b063a1 100644
--- a/examples/vite/src/AppSettings/AppSettings.tsx
+++ b/examples/vite/src/AppSettings/AppSettings.tsx
@@ -2,7 +2,6 @@ import React from 'react';
import {
Button,
ChatViewSelectorButton,
- createIcon,
GlobalModal,
IconBell,
IconEmoji,
@@ -10,39 +9,22 @@ import {
} from 'stream-chat-react';
import { type ComponentType, useState } from 'react';
-const IconGear = createIcon(
- 'IconGear',
- ,
- { viewBox: '0 0 16 16' },
-);
-
-const IconLightBulb = createIcon(
- 'IconLightBulb',
- <>
-
-
- >,
- { viewBox: '0 0 16 16' },
-);
-const IconTextDirection = createIcon(
- 'IconTextDirection',
- ,
- { viewBox: '0 0 16 16' },
-);
-
import { ActionsMenu } from './ActionsMenu';
import { GeneralTab } from './tabs/General';
+import { MessageActionsTab } from './tabs/MessageActions';
import { NotificationsTab } from './tabs/Notifications';
import { ReactionsTab } from './tabs/Reactions';
import { SidebarTab } from './tabs/Sidebar';
import { appSettingsStore, useAppSettingsState } from './state';
+import { IconGear, IconLightBulb, IconSidebar, IconTextDirection } from '../icons.tsx';
-type TabId = 'general' | 'notifications' | 'reactions' | 'sidebar';
+type TabId = 'general' | 'messageActions' | 'notifications' | 'reactions' | 'sidebar';
const tabConfig: { Icon: ComponentType; id: TabId; title: string }[] = [
{ Icon: IconGear, id: 'general', title: 'General' },
+ { Icon: IconMessageBubble, id: 'messageActions', title: 'Message Actions' },
{ Icon: IconBell, id: 'notifications', title: 'Notifications' },
- { Icon: IconMessageBubble, id: 'sidebar', title: 'Sidebar' },
+ { Icon: IconSidebar, id: 'sidebar', title: 'Sidebar' },
{ Icon: IconEmoji, id: 'reactions', title: 'Reactions' },
];
@@ -151,6 +133,7 @@ export const AppSettings = ({ iconOnly = true }: { iconOnly?: boolean }) => {
role='tabpanel'
>
{activeTab === 'general' && }
+ {activeTab === 'messageActions' && }
{activeTab === 'notifications' && }
{activeTab === 'sidebar' && }
{activeTab === 'reactions' && }
diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts
index af7a4c1664..9eb0310046 100644
--- a/examples/vite/src/AppSettings/state.ts
+++ b/examples/vite/src/AppSettings/state.ts
@@ -20,6 +20,15 @@ export type NotificationsSettingsState = {
verticalAlignment: 'bottom' | 'top';
};
+export type MessageActionsSettingsState = {
+ customMessageActions: {
+ delete: {
+ enableOptionConfiguration: boolean;
+ };
+ markOwnUnread: boolean;
+ };
+};
+
export const LEFT_PANEL_MIN_WIDTH = 260;
export const THREAD_PANEL_MIN_WIDTH = 260;
@@ -40,6 +49,7 @@ export type PanelLayoutSettingsState = {
export type AppSettingsState = {
chatView: ChatViewSettingsState;
+ messageActions: MessageActionsSettingsState;
notifications: NotificationsSettingsState;
panelLayout: PanelLayoutSettingsState;
reactions: ReactionsSettingsState;
@@ -66,6 +76,14 @@ const defaultAppSettingsState: AppSettingsState = {
chatView: {
iconOnly: true,
},
+ messageActions: {
+ customMessageActions: {
+ delete: {
+ enableOptionConfiguration: false,
+ },
+ markOwnUnread: false,
+ },
+ },
notifications: {
verticalAlignment: 'bottom',
},
diff --git a/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx b/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx
new file mode 100644
index 0000000000..0aa459928f
--- /dev/null
+++ b/examples/vite/src/AppSettings/tabs/MessageActions/MessageActionsTab.tsx
@@ -0,0 +1,61 @@
+import { SwitchField } from 'stream-chat-react';
+import { appSettingsStore, useAppSettingsState } from '../../state';
+
+export const MessageActionsTab = () => {
+ const {
+ messageActions,
+ messageActions: { customMessageActions },
+ } = useAppSettingsState();
+
+ return (
+
+
+
Delete message
+
+ appSettingsStore.partialNext({
+ messageActions: {
+ ...messageActions,
+ customMessageActions: {
+ ...customMessageActions,
+ delete: {
+ enableOptionConfiguration: event.target.checked,
+ },
+ },
+ },
+ })
+ }
+ >
+ Enabled option configuration
+
+
+ It enables to configure delete request params in the Delete Message Alert like
+ "Delete only for me", "Hard delete".
+
+
+
+
+
Mark as unread
+
+ appSettingsStore.partialNext({
+ messageActions: {
+ ...messageActions,
+ customMessageActions: {
+ ...customMessageActions,
+ markOwnUnread: event.target.checked,
+ },
+ },
+ })
+ }
+ >
+ Mark own messages as unread too
+
+
+
+ );
+};
diff --git a/examples/vite/src/AppSettings/tabs/MessageActions/index.ts b/examples/vite/src/AppSettings/tabs/MessageActions/index.ts
new file mode 100644
index 0000000000..e93c40ccfa
--- /dev/null
+++ b/examples/vite/src/AppSettings/tabs/MessageActions/index.ts
@@ -0,0 +1 @@
+export * from './MessageActionsTab';
diff --git a/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx b/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx
new file mode 100644
index 0000000000..59d73e1186
--- /dev/null
+++ b/examples/vite/src/CustomMessageActions/ConfigurableMessageActions.tsx
@@ -0,0 +1,375 @@
+import { createContext, useCallback, useContext, useMemo, useState } from 'react';
+import type { DeleteMessageOptions, LocalMessage } from 'stream-chat';
+import {
+ Alert,
+ Button,
+ ContextMenuButton,
+ defaultMessageActionSet,
+ GlobalModal,
+ IconDelete,
+ IconNotification,
+ MessageActions,
+ SwitchField,
+ type MessageActionSetItem,
+ useComponentContext,
+ useContextMenuContext,
+ useMessageContext,
+ useModalContext,
+ useNotificationApi,
+ useTranslationContext,
+} from 'stream-chat-react';
+
+import { useAppSettingsSelector } from '../AppSettings';
+
+const getErrorMessage = (error: unknown, fallback: string) =>
+ error instanceof Error && error.message ? error.message : fallback;
+
+const getNotificationError = (error: unknown): Error | undefined => {
+ if (error instanceof Error) return error;
+ if (typeof error === 'string') return new Error(error);
+ if (error && typeof error === 'object' && 'message' in error) {
+ const message = error.message;
+
+ if (typeof message === 'string') return new Error(message);
+ }
+};
+
+type CustomDeleteMessageAlertProps = {
+ enableOptionConfiguration: boolean;
+ onCancel: () => void;
+ onDelete: (options: DeleteMessageOptions) => Promise;
+};
+
+const CustomDeleteMessageAlert = ({
+ enableOptionConfiguration,
+ onCancel,
+ onDelete,
+}: CustomDeleteMessageAlertProps) => {
+ const { t } = useTranslationContext();
+ const { close } = useModalContext();
+ const [deleteForMe, setDeleteForMe] = useState(false);
+ const [hardDelete, setHardDelete] = useState(false);
+ const [softDelete, setSoftDelete] = useState(true);
+
+ return (
+
+
+ {enableOptionConfiguration && (
+
+ setDeleteForMe(event.target.checked)}
+ >
+ {t('Delete for me only')}
+
+ {
+ const checked = event.target.checked;
+
+ setHardDelete(checked);
+ if (checked) setSoftDelete(false);
+ if (!checked && !softDelete) setSoftDelete(true);
+ }}
+ >
+ {t('Hard delete')}
+
+ {
+ const checked = event.target.checked;
+
+ setSoftDelete(checked);
+ if (checked) setHardDelete(false);
+ if (!checked && !hardDelete) setHardDelete(true);
+ }}
+ >
+ {t('Soft delete')}
+
+
+ )}
+
+
+
+
+
+ );
+};
+
+type OpenDeleteDialogParams = {
+ handleDelete: (options?: DeleteMessageOptions) => Promise | void;
+ message: LocalMessage;
+};
+
+type CustomDeleteActionContextValue = {
+ openDeleteDialog: (params: OpenDeleteDialogParams) => void;
+};
+
+const CustomDeleteActionContext = createContext<
+ CustomDeleteActionContextValue | undefined
+>(undefined);
+
+const useCustomDeleteActionContext = () => {
+ const contextValue = useContext(CustomDeleteActionContext);
+
+ if (!contextValue) {
+ throw new Error(
+ 'useCustomDeleteActionContext must be used within CustomDeleteActionContext.Provider',
+ );
+ }
+
+ return contextValue;
+};
+
+const CustomDeleteMessageAction = () => {
+ const { closeMenu } = useContextMenuContext();
+ const { openDeleteDialog } = useCustomDeleteActionContext();
+ const { handleDelete, message } = useMessageContext();
+ const { t } = useTranslationContext();
+
+ return (
+ {
+ openDeleteDialog({ handleDelete, message });
+ closeMenu();
+ }}
+ variant='destructive'
+ >
+ {t('Delete message')}
+
+ );
+};
+
+const CustomMarkOwnUnreadMessageAction = () => {
+ const { closeMenu } = useContextMenuContext();
+ const { handleMarkUnread, isMyMessage, message } = useMessageContext();
+ const { addNotification } = useNotificationApi();
+ const { t } = useTranslationContext();
+
+ if (!isMyMessage()) return null;
+
+ return (
+ {
+ try {
+ await handleMarkUnread(event);
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
+ message: t('Message marked as unread'),
+ severity: 'success',
+ type: 'api:message:markUnread:success',
+ });
+ } catch (error) {
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
+ error: getNotificationError(error),
+ message: getErrorMessage(
+ error,
+ t(
+ 'Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.',
+ ),
+ ),
+ severity: 'error',
+ type: 'api:message:markUnread:failed',
+ });
+ } finally {
+ closeMenu();
+ }
+ }}
+ >
+ {t('Mark as unread')}
+
+ );
+};
+
+type SupportedCustomMessageActionType = 'delete' | 'markOwnUnread';
+
+type CustomMessageActionOverrideSpec = {
+ actionSetItem: MessageActionSetItem;
+ insertBeforeType?: string;
+ enabled: boolean;
+ mode: 'append' | 'replace';
+};
+
+const applyCustomMessageActionOverrides = ({
+ messageActionSet,
+ overrides,
+}: {
+ messageActionSet: MessageActionSetItem[];
+ overrides: CustomMessageActionOverrideSpec[];
+}) =>
+ overrides.reduce((acc, { actionSetItem, enabled, insertBeforeType, mode }) => {
+ if (!enabled || actionSetItem.placement === 'quick-dropdown-toggle') return acc;
+ const actionType = actionSetItem.type;
+ if (mode === 'append') {
+ if (!insertBeforeType) return [...acc, actionSetItem];
+
+ const insertBeforeIndex = acc.findIndex(
+ (messageActionSetItem) =>
+ 'type' in messageActionSetItem &&
+ messageActionSetItem.type === insertBeforeType,
+ );
+
+ if (insertBeforeIndex < 0) return [...acc, actionSetItem];
+
+ return [
+ ...acc.slice(0, insertBeforeIndex),
+ actionSetItem,
+ ...acc.slice(insertBeforeIndex),
+ ];
+ }
+
+ return [
+ ...acc.filter(
+ (messageActionSetItem) =>
+ !('type' in messageActionSetItem) || messageActionSetItem.type !== actionType,
+ ),
+ actionSetItem,
+ ];
+ }, messageActionSet);
+
+export const ConfigurableMessageActions = (
+ props: React.ComponentProps,
+) => {
+ const { addNotification } = useNotificationApi();
+ const { Modal = GlobalModal } = useComponentContext();
+ const [deleteDialogTarget, setDeleteDialogTarget] =
+ useState(null);
+ const { t } = useTranslationContext();
+ const { customMessageActions } = useAppSettingsSelector(
+ (state) => state.messageActions,
+ );
+ const configurableActionSet = useMemo(() => {
+ const actionSet = props.messageActionSet ?? defaultMessageActionSet;
+ const actionOverrides: Record<
+ SupportedCustomMessageActionType,
+ Omit
+ > = {
+ delete: {
+ actionSetItem: {
+ Component: CustomDeleteMessageAction,
+ placement: 'dropdown',
+ type: 'delete',
+ },
+ mode: 'replace',
+ },
+ markOwnUnread: {
+ actionSetItem: {
+ Component: CustomMarkOwnUnreadMessageAction,
+ placement: 'dropdown',
+ type: 'markOwnUnread',
+ },
+ insertBeforeType: 'remindMe',
+ mode: 'append',
+ },
+ };
+
+ const overrides: CustomMessageActionOverrideSpec[] = [
+ {
+ ...actionOverrides.delete,
+ enabled: true,
+ },
+ {
+ ...actionOverrides.markOwnUnread,
+ enabled: customMessageActions.markOwnUnread,
+ },
+ ];
+
+ return applyCustomMessageActionOverrides({ messageActionSet: actionSet, overrides });
+ }, [customMessageActions, props.messageActionSet]);
+ const openDeleteDialog = useCallback((params: OpenDeleteDialogParams) => {
+ setDeleteDialogTarget(params);
+ }, []);
+
+ return (
+
+
+ {
+ setDeleteDialogTarget(null);
+ }}
+ >
+ {deleteDialogTarget && (
+ {
+ setDeleteDialogTarget(null);
+ }}
+ onDelete={async (options) => {
+ try {
+ await deleteDialogTarget.handleDelete(options);
+ addNotification({
+ context: {
+ message: deleteDialogTarget.message,
+ },
+ emitter: 'MessageActions',
+ message: t('Message deleted'),
+ severity: 'success',
+ type: 'api:message:delete:success',
+ });
+ } catch (error) {
+ addNotification({
+ context: {
+ message: deleteDialogTarget.message,
+ },
+ emitter: 'MessageActions',
+ error: getNotificationError(error),
+ message: getErrorMessage(error, t('Error deleting message')),
+ severity: 'error',
+ type: 'api:message:delete:failed',
+ });
+ } finally {
+ setDeleteDialogTarget(null);
+ }
+ }}
+ />
+ )}
+
+
+ );
+};
diff --git a/examples/vite/src/CustomMessageActions/CustomMessageActions.scss b/examples/vite/src/CustomMessageActions/CustomMessageActions.scss
new file mode 100644
index 0000000000..75c04173e2
--- /dev/null
+++ b/examples/vite/src/CustomMessageActions/CustomMessageActions.scss
@@ -0,0 +1,18 @@
+@layer stream-app-overrides {
+ .app__custom-delete-message-alert__options {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 12px;
+ width: 100%;
+ }
+
+ .app__custom-delete-message-alert__options .str-chat__form__switch-field {
+ display: flex;
+ justify-content: space-between;
+ border: 1px solid var(--border-core-default);
+ border-radius: 10px;
+ padding: 6px 8px;
+ font: var(--str-chat__metadata-emphasis-text);
+ }
+}
diff --git a/examples/vite/src/CustomMessageActions/index.ts b/examples/vite/src/CustomMessageActions/index.ts
new file mode 100644
index 0000000000..b40f7e70ff
--- /dev/null
+++ b/examples/vite/src/CustomMessageActions/index.ts
@@ -0,0 +1 @@
+export * from './ConfigurableMessageActions';
diff --git a/examples/vite/src/Sidebar/SidebarToggle.tsx b/examples/vite/src/Sidebar/SidebarToggle.tsx
new file mode 100644
index 0000000000..25902fc4a7
--- /dev/null
+++ b/examples/vite/src/Sidebar/SidebarToggle.tsx
@@ -0,0 +1,21 @@
+import { useSidebar } from '../ChatLayout/SidebarContext.tsx';
+import { Button, useTranslationContext } from 'stream-chat-react';
+import { IconSidebar } from '../icons.tsx';
+
+export const SidebarToggle = () => {
+ const { closeSidebar, openSidebar, sidebarOpen } = useSidebar();
+ const { t } = useTranslationContext();
+ return (
+
+ );
+};
diff --git a/examples/vite/src/SystemNotification/SystemNotification.tsx b/examples/vite/src/SystemNotification/SystemNotification.tsx
index dd9460d0b3..ad2c1463e6 100644
--- a/examples/vite/src/SystemNotification/SystemNotification.tsx
+++ b/examples/vite/src/SystemNotification/SystemNotification.tsx
@@ -7,21 +7,9 @@ import {
IconExclamationCircleFill,
IconExclamationTriangleFill,
IconLoading,
- createIcon,
useSystemNotifications,
} from 'stream-chat-react';
-
-export const IconInfoCircle = createIcon(
- 'IconInfoCircle',
- ,
-);
+import { IconInfoCircle } from '../icons.tsx';
const IconsBySeverity: Record = {
error: IconExclamationCircleFill,
info: IconInfoCircle,
diff --git a/examples/vite/src/icons.tsx b/examples/vite/src/icons.tsx
new file mode 100644
index 0000000000..f116376843
--- /dev/null
+++ b/examples/vite/src/icons.tsx
@@ -0,0 +1,46 @@
+import { createIcon } from 'stream-chat-react';
+
+export const IconSidebar = createIcon(
+ 'IconSidebar',
+ ,
+);
+
+export const IconGear = createIcon(
+ 'IconGear',
+ ,
+ { viewBox: '0 0 16 16' },
+);
+
+export const IconLightBulb = createIcon(
+ 'IconLightBulb',
+ <>
+
+
+ >,
+ { viewBox: '0 0 16 16' },
+);
+
+export const IconTextDirection = createIcon(
+ 'IconTextDirection',
+ ,
+ { viewBox: '0 0 16 16' },
+);
+
+export const IconInfoCircle = createIcon(
+ 'IconInfoCircle',
+ ,
+);
diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss
index eae6820d30..96e91598f7 100644
--- a/examples/vite/src/index.scss
+++ b/examples/vite/src/index.scss
@@ -3,6 +3,8 @@
// v3 CSS import
@import url('stream-chat-react/dist/css/index.css') layer(stream-new);
@import url('./AppSettings/AppSettings.scss') layer(stream-app-overrides);
+@import url('./CustomMessageActions/CustomMessageActions.scss')
+layer(stream-app-overrides);
@import url('./SystemNotification/SystemNotification.scss') layer(stream-app-overrides);
@import url('stream-chat-react/dist/css/emoji-picker.css') layer(stream-new-plugins);
diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx
index 7dbf6a602c..27ef65aa72 100644
--- a/src/components/Chat/Chat.tsx
+++ b/src/components/Chat/Chat.tsx
@@ -77,7 +77,6 @@ export const Chat = (props: PropsWithChildren) => {
i18nInstance,
});
- useReportLostConnectionSystemNotification();
const channelsQueryState = useChannelsQueryState();
const searchController = useMemo(
diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts
index 27cb933647..a5e7590345 100644
--- a/src/components/Form/index.ts
+++ b/src/components/Form/index.ts
@@ -1,5 +1,6 @@
export * from './Dropdown';
export * from './FieldError';
export * from './NumericInput';
+export * from './SwitchField';
export * from './TextInput';
export * from './TextInputFieldSet';