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';