From 9751e15c53ccdcbf76b4c61995cb20ee0d20e37a Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 4 Sep 2024 17:44:30 +0200 Subject: [PATCH 01/29] feat: add centralized dialog management --- src/components/Channel/Channel.tsx | 39 +-- src/components/Dialog/DialogAnchor.tsx | 79 ++++++ src/components/Dialog/DialogPortal.tsx | 62 +++++ src/components/Dialog/DialogsManager.ts | 178 ++++++++++++ src/components/Dialog/hooks/index.ts | 1 + src/components/Dialog/hooks/useDialog.ts | 32 +++ src/components/Dialog/index.ts | 4 + .../Message/hooks/useReactionHandler.ts | 13 +- .../MessageActions/MessageActions.tsx | 101 +++---- .../MessageActions/MessageActionsBox.tsx | 259 ++++++++---------- src/components/index.ts | 1 + src/context/DialogsManagerContext.tsx | 28 ++ src/context/index.ts | 1 + 13 files changed, 575 insertions(+), 223 deletions(-) create mode 100644 src/components/Dialog/DialogAnchor.tsx create mode 100644 src/components/Dialog/DialogPortal.tsx create mode 100644 src/components/Dialog/DialogsManager.ts create mode 100644 src/components/Dialog/hooks/index.ts create mode 100644 src/components/Dialog/hooks/useDialog.ts create mode 100644 src/components/Dialog/index.ts create mode 100644 src/context/DialogsManagerContext.tsx diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index c8c258dd7..f33613504 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -76,6 +76,10 @@ import type { UnreadMessagesNotificationProps } from '../MessageList'; import { hasMoreMessagesProbably, UnreadMessagesSeparator } from '../MessageList'; import { useChannelContainerClasses } from './hooks/useChannelContainerClasses'; import { findInMsgSetByDate, findInMsgSetById, makeAddNotifications } from './utils'; +import { DateSeparator } from '../DateSeparator'; +import { DialogsManagerProvider } from '../Dialog'; +import { EventComponent } from '../EventComponent'; +import { defaultReactionOptions, ReactionOptions } from '../Reactions'; import { getChannel } from '../../utils'; import type { MessageProps } from '../Message/types'; @@ -96,9 +100,6 @@ import { getVideoAttachmentConfiguration, } from '../Attachment/attachment-sizing'; import type { URLEnrichmentConfig } from '../MessageInput/hooks/useLinkPreviews'; -import { defaultReactionOptions, ReactionOptions } from '../Reactions'; -import { EventComponent } from '../EventComponent'; -import { DateSeparator } from '../DateSeparator'; type ChannelPropsForwardedToComponentContext< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics @@ -1241,7 +1242,7 @@ const ChannelInner = < ], ); - const componentContextValue: ComponentContextValue = useMemo( + const componentContextValue = useMemo>( () => ({ Attachment: props.Attachment || DefaultAttachment, AttachmentPreviewList: props.AttachmentPreviewList, @@ -1329,20 +1330,22 @@ const ChannelInner = < return (
- - - - -
- {dragAndDropWindow && ( - {children} - )} - {!dragAndDropWindow && <>{children}} -
-
-
-
-
+ + + + + +
+ {dragAndDropWindow && ( + {children} + )} + {!dragAndDropWindow && <>{children}} +
+
+
+
+
+
); }; diff --git a/src/components/Dialog/DialogAnchor.tsx b/src/components/Dialog/DialogAnchor.tsx new file mode 100644 index 000000000..31e62dcec --- /dev/null +++ b/src/components/Dialog/DialogAnchor.tsx @@ -0,0 +1,79 @@ +import { Placement } from '@popperjs/core'; +import React, { ComponentProps, PropsWithChildren, useEffect, useRef } from 'react'; +import { usePopper } from 'react-popper'; +import { useDialogIsOpen } from './hooks'; +import { DialogPortalEntry } from './DialogPortal'; + +export interface DialogAnchorOptions { + open: boolean; + placement: Placement; + referenceElement: HTMLElement | null; +} + +export function useDialogAnchor({ + open, + placement, + referenceElement, +}: DialogAnchorOptions) { + const popperElementRef = useRef(null); + const { attributes, styles, update } = usePopper(referenceElement, popperElementRef.current, { + modifiers: [ + { + name: 'eventListeners', + options: { + // It's not safe to update popper position on resize and scroll, since popper's + // reference element might not be visible at the time. + resize: false, + scroll: false, + }, + }, + ], + placement, + }); + + useEffect(() => { + if (open) { + // Since the popper's reference element might not be (and usually is not) visible + // all the time, it's safer to force popper update before showing it. + update?.(); + } + }, [open, update]); + + return { + attributes, + popperElementRef, + styles, + }; +} + +type DialogAnchorProps = PropsWithChildren> & { + id: string; +} & ComponentProps<'div' | 'span'>; + +export const DialogAnchor = ({ + children, + className, + id, + placement = 'auto', + referenceElement = null, +}: DialogAnchorProps) => { + const open = useDialogIsOpen(id); + const { attributes, popperElementRef, styles } = useDialogAnchor({ + open, + placement, + referenceElement, + }); + + return ( + +
+ {children} +
+
+ ); +}; diff --git a/src/components/Dialog/DialogPortal.tsx b/src/components/Dialog/DialogPortal.tsx new file mode 100644 index 000000000..9110b42e5 --- /dev/null +++ b/src/components/Dialog/DialogPortal.tsx @@ -0,0 +1,62 @@ +import React, { PropsWithChildren, useEffect, useLayoutEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import type { DialogsManager } from './DialogsManager'; +import { useDialogIsOpen } from './hooks'; +import { useDialogsManager } from '../../context'; + +export const DialogPortalDestination = () => { + const { dialogsManager } = useDialogsManager(); + const [shouldRender, setShouldRender] = useState(!!dialogsManager.openDialogCount); + useEffect( + () => + dialogsManager.on('openCountChange', { + listener: (dm: DialogsManager) => { + setShouldRender(dm.openDialogCount > 0); + }, + }), + [dialogsManager], + ); + + return ( + <> +
dialogsManager.closeAll()} + style={{ + height: '100%', + inset: '0', + overflow: 'hidden', + position: 'absolute', + width: '100%', + zIndex: shouldRender ? '2' : '-1', + }} + > +
+
+ + ); +}; + +type DialogPortalEntryProps = { + dialogId: string; +}; + +export const DialogPortalEntry = ({ + children, + dialogId, +}: PropsWithChildren) => { + const { dialogsManager } = useDialogsManager(); + const dialogIsOpen = useDialogIsOpen(dialogId); + const [portalDestination, setPortalDestination] = useState(null); + useLayoutEffect(() => { + const destination = document.querySelector( + `div[data-str-chat__portal-id="${dialogsManager.id}"]`, + ); + if (!destination) return; + setPortalDestination(destination); + }, [dialogsManager, dialogIsOpen]); + + if (!portalDestination) return null; + + return createPortal(children, portalDestination); +}; diff --git a/src/components/Dialog/DialogsManager.ts b/src/components/Dialog/DialogsManager.ts new file mode 100644 index 000000000..c15a7052d --- /dev/null +++ b/src/components/Dialog/DialogsManager.ts @@ -0,0 +1,178 @@ +type DialogId = string; + +export type GetOrCreateParams = { + id: DialogId; + isOpen?: boolean; +}; + +export type Dialog = { + close: () => void; + id: DialogId; + isOpen: boolean | undefined; + open: (zIndex?: number) => void; + remove: () => void; + toggle: () => void; + toggleSingle: () => void; +}; + +type DialogEvent = { type: 'close' | 'open' | 'openCountChange' }; + +const dialogsManagerEvents = ['openCountChange'] as const; +type DialogsManagerEvent = { type: typeof dialogsManagerEvents[number] }; + +type DialogEventHandler = (dialog: Dialog) => void; +type DialogsManagerEventHandler = (dialogsManager: DialogsManager) => void; + +type DialogInitOptions = { + id?: string; +}; + +const noop = (): void => undefined; + +export class DialogsManager { + id: string; + openDialogCount = 0; + dialogs: Record = {}; + private dialogEventListeners: Record< + DialogId, + Partial> + > = {}; + private dialogsManagerEventListeners: Record< + DialogsManagerEvent['type'], + DialogsManagerEventHandler[] + > = { openCountChange: [] }; + + constructor({ id }: DialogInitOptions = {}) { + this.id = id ?? new Date().getTime().toString(); + } + + getOrCreate({ id, isOpen = false }: GetOrCreateParams) { + let dialog = this.dialogs[id]; + if (!dialog) { + dialog = { + close: () => { + this.close(id); + }, + id, + isOpen, + open: () => { + this.open({ id }); + }, + remove: () => { + this.remove(id); + }, + toggle: () => { + this.toggleOpen({ id }); + }, + toggleSingle: () => { + this.toggleOpenSingle({ id }); + }, + }; + this.dialogs[id] = dialog; + } + return dialog; + } + + on( + eventType: DialogEvent['type'] | DialogsManagerEvent['type'], + { id, listener }: { listener: DialogEventHandler | DialogsManagerEventHandler; id?: DialogId }, + ) { + if (dialogsManagerEvents.includes(eventType as DialogsManagerEvent['type'])) { + this.dialogsManagerEventListeners[eventType as DialogsManagerEvent['type']].push( + listener as DialogsManagerEventHandler, + ); + return () => { + this.off(eventType, { listener }); + }; + } + if (!id) return noop; + + if (!this.dialogEventListeners[id]) { + this.dialogEventListeners[id] = { close: [], open: [] }; + } + this.dialogEventListeners[id][eventType] = [ + ...(this.dialogEventListeners[id][eventType] ?? []), + listener as DialogEventHandler, + ]; + return () => { + this.off(eventType, { id, listener }); + }; + } + + off( + eventType: DialogEvent['type'] | DialogsManagerEvent['type'], + { id, listener }: { listener: DialogEventHandler | DialogsManagerEventHandler; id?: DialogId }, + ) { + if (dialogsManagerEvents.includes(eventType as DialogsManagerEvent['type'])) { + const eventListeners = this.dialogsManagerEventListeners[ + eventType as DialogsManagerEvent['type'] + ]; + eventListeners?.filter((l) => l !== listener); + return; + } + + if (!id) return; + + const eventListeners = this.dialogEventListeners[id]?.[eventType]; + if (!eventListeners) return; + this.dialogEventListeners[id][eventType] = eventListeners.filter((l) => l !== listener); + } + + open(params: GetOrCreateParams, single?: boolean) { + const dialog = this.getOrCreate(params); + if (dialog.isOpen) return; + if (single) { + this.closeAll(); + } + this.dialogs[params.id].isOpen = true; + this.openDialogCount++; + this.dialogsManagerEventListeners.openCountChange.forEach((listener) => listener(this)); + this.dialogEventListeners[params.id].open?.forEach((listener) => listener(dialog)); + } + + close(id: DialogId) { + const dialog = this.dialogs[id]; + if (!dialog?.isOpen) return; + dialog.isOpen = false; + this.openDialogCount--; + this.dialogEventListeners[id].close?.forEach((listener) => listener(dialog)); + this.dialogsManagerEventListeners.openCountChange.forEach((listener) => listener(this)); + } + + closeAll() { + Object.values(this.dialogs).forEach((dialog) => dialog.close()); + } + + toggleOpen(params: GetOrCreateParams) { + if (this.dialogs[params.id].isOpen) { + this.close(params.id); + } else { + this.open(params); + } + } + + toggleOpenSingle(params: GetOrCreateParams) { + if (this.dialogs[params.id].isOpen) { + this.close(params.id); + } else { + this.open(params, true); + } + } + + remove(id: DialogId) { + const dialogs = { ...this.dialogs }; + if (!dialogs[id]) return; + + const countListeners = + !!this.dialogEventListeners[id] && + Object.values(this.dialogEventListeners[id]).reduce((acc, listeners) => { + acc += listeners.length; + return acc; + }, 0); + + if (!countListeners) { + delete this.dialogEventListeners[id]; + delete dialogs[id]; + } + } +} diff --git a/src/components/Dialog/hooks/index.ts b/src/components/Dialog/hooks/index.ts new file mode 100644 index 000000000..9d08c250c --- /dev/null +++ b/src/components/Dialog/hooks/index.ts @@ -0,0 +1 @@ +export * from './useDialog'; diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts new file mode 100644 index 000000000..802fe5761 --- /dev/null +++ b/src/components/Dialog/hooks/useDialog.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; +import { useDialogsManager } from '../../../context/DialogsManagerContext'; +import type { GetOrCreateParams } from '../DialogsManager'; + +export const useDialog = ({ id, isOpen }: GetOrCreateParams) => { + const { dialogsManager } = useDialogsManager(); + + useEffect( + () => () => { + dialogsManager.remove(id); + }, + [dialogsManager, id], + ); + + return dialogsManager.getOrCreate({ id, isOpen }); +}; + +export const useDialogIsOpen = (id: string, source?: string) => { + const { dialogsManager } = useDialogsManager(); + const [open, setOpen] = useState(false); + + useEffect(() => { + const unsubscribeOpen = dialogsManager.on('open', { id, listener: () => setOpen(true) }); + const unsubscribeClose = dialogsManager.on('close', { id, listener: () => setOpen(false) }); + return () => { + unsubscribeOpen(); + unsubscribeClose(); + }; + }, [dialogsManager, id, source]); + + return open; +}; diff --git a/src/components/Dialog/index.ts b/src/components/Dialog/index.ts new file mode 100644 index 000000000..3bfd1c2dc --- /dev/null +++ b/src/components/Dialog/index.ts @@ -0,0 +1,4 @@ +export * from './DialogAnchor'; +export * from './DialogsManager'; +export * from '../../context/DialogsManagerContext'; +export * from './hooks'; diff --git a/src/components/Message/hooks/useReactionHandler.ts b/src/components/Message/hooks/useReactionHandler.ts index e057dceb6..b7275801d 100644 --- a/src/components/Message/hooks/useReactionHandler.ts +++ b/src/components/Message/hooks/useReactionHandler.ts @@ -174,8 +174,7 @@ export const useReactionClick = < setShowDetailedReactions(false); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [setShowDetailedReactions, reactionSelectorRef], + [closeReactionSelectorOnClick, setShowDetailedReactions, reactionSelectorRef], ); useEffect(() => { @@ -184,18 +183,12 @@ export const useReactionClick = < if (showDetailedReactions && !hasListener.current) { hasListener.current = true; document.addEventListener('click', closeDetailedReactions); - - if (messageWrapper) { - messageWrapper.addEventListener('mouseleave', closeDetailedReactions); - } + messageWrapper?.addEventListener('mouseleave', closeDetailedReactions); } if (!showDetailedReactions && hasListener.current) { document.removeEventListener('click', closeDetailedReactions); - - if (messageWrapper) { - messageWrapper.removeEventListener('mouseleave', closeDetailedReactions); - } + messageWrapper?.removeEventListener('mouseleave', closeDetailedReactions); hasListener.current = false; } diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx index 194313dde..6c93c64d0 100644 --- a/src/components/MessageActions/MessageActions.tsx +++ b/src/components/MessageActions/MessageActions.tsx @@ -1,23 +1,16 @@ -import React, { - ElementRef, - PropsWithChildren, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; +import clsx from 'clsx'; +import React, { ElementRef, PropsWithChildren, useCallback, useEffect, useRef } from 'react'; import { MessageActionsBox } from './MessageActionsBox'; +import { DialogAnchor, useDialog, useDialogIsOpen } from '../Dialog'; import { ActionsIcon as DefaultActionsIcon } from '../Message/icons'; import { isUserMuted } from '../Message/utils'; - import { useChatContext } from '../../context/ChatContext'; import { MessageContextValue, useMessageContext } from '../../context/MessageContext'; +import { useTranslationContext } from '../../context'; import type { DefaultStreamChatGenerics, IconProps } from '../../types/types'; -import { useMessageActionsBoxPopper } from './hooks'; -import { useTranslationContext } from '../../context'; type MessageContextPropsToPick = | 'getMessageActions' @@ -88,16 +81,21 @@ export const MessageActions = < const message = propMessage || contextMessage; const isMine = mine ? mine() : isMyMessage(); - const [actionsBoxOpen, setActionsBoxOpen] = useState(false); - const isMuted = useCallback(() => isUserMuted(message, mutes), [message, mutes]); - const hideOptions = useCallback((event: MouseEvent | KeyboardEvent) => { - if (event instanceof KeyboardEvent && event.key !== 'Escape') { - return; - } - setActionsBoxOpen(false); - }, []); + const dialogId = `message-actions--${message.id}`; + const dialog = useDialog({ id: dialogId }); + const dialogIsOpen = useDialogIsOpen(dialogId); + + const hideOptions = useCallback( + (event: MouseEvent | KeyboardEvent) => { + if (event instanceof KeyboardEvent && event.key !== 'Escape') { + return; + } + dialog?.close(); + }, + [dialog], + ); const messageActions = getMessageActions(); const messageDeletedAt = !!message?.deleted_at; @@ -114,50 +112,46 @@ export const MessageActions = < }, [hideOptions, messageDeletedAt]); useEffect(() => { - if (!actionsBoxOpen) return; + if (!dialogIsOpen) return; - document.addEventListener('click', hideOptions); document.addEventListener('keyup', hideOptions); return () => { - document.removeEventListener('click', hideOptions); document.removeEventListener('keyup', hideOptions); }; - }, [actionsBoxOpen, hideOptions]); + }, [dialog, dialogIsOpen, hideOptions]); const actionsBoxButtonRef = useRef>(null); - const { attributes, popperElementRef, styles } = useMessageActionsBoxPopper({ - open: actionsBoxOpen, - placement: isMine ? 'top-end' : 'top-start', - referenceElement: actionsBoxButtonRef.current, - }); - if (!messageActions.length && !customMessageActions) return null; return ( - + + + + )} + {messageActions.indexOf(MESSAGE_ACTIONS.pin) > -1 && !message.parent_id && ( + + )} + {messageActions.indexOf(MESSAGE_ACTIONS.markUnread) > -1 && !threadList && !!message.id && ( + + )} + {messageActions.indexOf(MESSAGE_ACTIONS.flag) > -1 && ( + + )} + {messageActions.indexOf(MESSAGE_ACTIONS.mute) > -1 && ( + + )} + {messageActions.indexOf(MESSAGE_ACTIONS.edit) > -1 && ( + + )} + {messageActions.indexOf(MESSAGE_ACTIONS.delete) > -1 && ( + - )} - {messageActions.indexOf(MESSAGE_ACTIONS.pin) > -1 && !message.parent_id && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.markUnread) > -1 && !threadList && !!message.id && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.flag) > -1 && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.mute) > -1 && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.edit) > -1 && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.delete) > -1 && ( - - )} -
- - ); - }, -); + {t('Delete')} + + )} + + ); +}; /** * A popup box that displays the available actions on a message, such as edit, delete, pin, etc. diff --git a/src/components/index.ts b/src/components/index.ts index 9b1a388cb..d4a6e8f08 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -11,6 +11,7 @@ export * from './ChatAutoComplete'; export * from './ChatDown'; export * from './CommandItem'; export * from './DateSeparator'; +export * from './Dialog'; export * from './EmoticonItem'; export * from './EmptyStateIndicator'; export * from './EventComponent'; diff --git a/src/context/DialogsManagerContext.tsx b/src/context/DialogsManagerContext.tsx new file mode 100644 index 000000000..3aa2ef648 --- /dev/null +++ b/src/context/DialogsManagerContext.tsx @@ -0,0 +1,28 @@ +import React, { useContext, useState } from 'react'; +import { PropsWithChildrenOnly } from '../types/types'; +import { DialogsManager } from '../components/Dialog/DialogsManager'; +import { DialogPortalDestination } from '../components/Dialog/DialogPortal'; + +type DialogsManagerProviderContextValue = { + dialogsManager: DialogsManager; +}; + +const DialogsManagerProviderContext = React.createContext< + DialogsManagerProviderContextValue | undefined +>(undefined); + +export const DialogsManagerProvider = ({ children }: PropsWithChildrenOnly) => { + const [dialogsManager] = useState(() => new DialogsManager()); + + return ( + + {children} + + + ); +}; + +export const useDialogsManager = () => { + const value = useContext(DialogsManagerProviderContext); + return value as DialogsManagerProviderContextValue; +}; diff --git a/src/context/index.ts b/src/context/index.ts index 21c075feb..1f4f3fa85 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -3,6 +3,7 @@ export * from './ChannelListContext'; export * from './ChannelStateContext'; export * from './ChatContext'; export * from './ComponentContext'; +export * from './DialogsManagerContext'; export * from './MessageContext'; export * from './MessageBounceContext'; export * from './MessageInputContext'; From 143555bbd8ddca1812d77c5a870ec33c6f65a625 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Sep 2024 12:38:38 +0200 Subject: [PATCH 02/29] feat: use own anchor root for DialogAnchor --- src/components/Dialog/DialogAnchor.tsx | 8 +- src/components/Dialog/DialogPortal.tsx | 27 ++- .../MessageActions/MessageActions.tsx | 5 +- .../MessageActions/MessageActionsBox.tsx | 163 ++++++++++-------- 4 files changed, 109 insertions(+), 94 deletions(-) diff --git a/src/components/Dialog/DialogAnchor.tsx b/src/components/Dialog/DialogAnchor.tsx index 31e62dcec..20c7434f0 100644 --- a/src/components/Dialog/DialogAnchor.tsx +++ b/src/components/Dialog/DialogAnchor.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import { Placement } from '@popperjs/core'; import React, { ComponentProps, PropsWithChildren, useEffect, useRef } from 'react'; import { usePopper } from 'react-popper'; @@ -48,7 +49,7 @@ export function useDialogAnchor({ type DialogAnchorProps = PropsWithChildren> & { id: string; -} & ComponentProps<'div' | 'span'>; +} & ComponentProps<'div'>; export const DialogAnchor = ({ children, @@ -56,6 +57,7 @@ export const DialogAnchor = ({ id, placement = 'auto', referenceElement = null, + ...restDivProps }: DialogAnchorProps) => { const open = useDialogIsOpen(id); const { attributes, popperElementRef, styles } = useDialogAnchor({ @@ -67,8 +69,10 @@ export const DialogAnchor = ({ return (
diff --git a/src/components/Dialog/DialogPortal.tsx b/src/components/Dialog/DialogPortal.tsx index 9110b42e5..e31a3582f 100644 --- a/src/components/Dialog/DialogPortal.tsx +++ b/src/components/Dialog/DialogPortal.tsx @@ -18,22 +18,17 @@ export const DialogPortalDestination = () => { ); return ( - <> -
dialogsManager.closeAll()} - style={{ - height: '100%', - inset: '0', - overflow: 'hidden', - position: 'absolute', - width: '100%', - zIndex: shouldRender ? '2' : '-1', - }} - > -
-
- +
dialogsManager.closeAll()} + style={ + { + '--str-chat__dialog-overlay-height': shouldRender ? '100%' : '0', + } as React.CSSProperties + } + >
); }; diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx index 9860f9b7d..41dd364e7 100644 --- a/src/components/MessageActions/MessageActions.tsx +++ b/src/components/MessageActions/MessageActions.tsx @@ -146,9 +146,6 @@ export const MessageActions = < toggleOpen={dialog?.toggleSingle} > - )} - {messageActions.indexOf(MESSAGE_ACTIONS.pin) > -1 && !message.parent_id && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.markUnread) > -1 && !threadList && !!message.id && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.flag) > -1 && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.mute) > -1 && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.edit) > -1 && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.delete) > -1 && ( - - )} +
+
+ + {messageActions.indexOf(MESSAGE_ACTIONS.quote) > -1 && ( + + )} + {messageActions.indexOf(MESSAGE_ACTIONS.pin) > -1 && !message.parent_id && ( + + )} + {messageActions.indexOf(MESSAGE_ACTIONS.markUnread) > -1 && !threadList && !!message.id && ( + + )} + {messageActions.indexOf(MESSAGE_ACTIONS.flag) > -1 && ( + + )} + {messageActions.indexOf(MESSAGE_ACTIONS.mute) > -1 && ( + + )} + {messageActions.indexOf(MESSAGE_ACTIONS.edit) > -1 && ( + + )} + {messageActions.indexOf(MESSAGE_ACTIONS.delete) > -1 && ( + + )} +
); }; From c3ed3175dfcff4a73f1f9c4bcb60e5a288dac2aa Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Sep 2024 13:10:56 +0200 Subject: [PATCH 03/29] feat: forward custom DialogsManager id via DialogsManagerProvider --- src/context/DialogsManagerContext.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/context/DialogsManagerContext.tsx b/src/context/DialogsManagerContext.tsx index 3aa2ef648..1e564e7ee 100644 --- a/src/context/DialogsManagerContext.tsx +++ b/src/context/DialogsManagerContext.tsx @@ -1,5 +1,4 @@ -import React, { useContext, useState } from 'react'; -import { PropsWithChildrenOnly } from '../types/types'; +import React, { PropsWithChildren, useContext, useState } from 'react'; import { DialogsManager } from '../components/Dialog/DialogsManager'; import { DialogPortalDestination } from '../components/Dialog/DialogPortal'; @@ -11,8 +10,8 @@ const DialogsManagerProviderContext = React.createContext< DialogsManagerProviderContextValue | undefined >(undefined); -export const DialogsManagerProvider = ({ children }: PropsWithChildrenOnly) => { - const [dialogsManager] = useState(() => new DialogsManager()); +export const DialogsManagerProvider = ({ children, id }: PropsWithChildren<{ id?: string }>) => { + const [dialogsManager] = useState(() => new DialogsManager({ id })); return ( From 71c768558627731eb9a4c143a62f5446c4922789 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Sep 2024 15:03:17 +0200 Subject: [PATCH 04/29] feat: apply dialogs manager to message lists only --- src/components/Channel/Channel.tsx | 31 ++-- src/components/MessageList/MessageList.tsx | 83 ++++++----- .../MessageList/VirtualizedMessageList.tsx | 139 +++++++++--------- 3 files changed, 128 insertions(+), 125 deletions(-) diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index f33613504..754028309 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -77,7 +77,6 @@ import { hasMoreMessagesProbably, UnreadMessagesSeparator } from '../MessageList import { useChannelContainerClasses } from './hooks/useChannelContainerClasses'; import { findInMsgSetByDate, findInMsgSetById, makeAddNotifications } from './utils'; import { DateSeparator } from '../DateSeparator'; -import { DialogsManagerProvider } from '../Dialog'; import { EventComponent } from '../EventComponent'; import { defaultReactionOptions, ReactionOptions } from '../Reactions'; import { getChannel } from '../../utils'; @@ -1330,22 +1329,20 @@ const ChannelInner = < return (
- - - - - -
- {dragAndDropWindow && ( - {children} - )} - {!dragAndDropWindow && <>{children}} -
-
-
-
-
-
+ + + + +
+ {dragAndDropWindow && ( + {children} + )} + {!dragAndDropWindow && <>{children}} +
+
+
+
+
); }; diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 30f1dea12..785982d27 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -20,6 +20,7 @@ import { ChannelStateContextValue, useChannelStateContext, } from '../../context/ChannelStateContext'; +import { DialogsManagerProvider } from '../../context'; import { useChatContext } from '../../context/ChatContext'; import { useComponentContext } from '../../context/ComponentContext'; import { MessageListContextProvider } from '../../context/MessageListContext'; @@ -225,47 +226,49 @@ const MessageListWithContext = < return ( - {!threadList && showUnreadMessagesNotification && ( - - )} -
- {showEmptyStateIndicator ? ( - - ) : ( - - {props.loadingMore && } -
- } - loadNextPage={loadMoreNewer} - loadPreviousPage={loadMore} - threshold={loadMoreScrollThreshold} - {...restInternalInfiniteScrollProps} - > -
    - {elements} -
- - -
- + + {!threadList && showUnreadMessagesNotification && ( + )} -
+
+ {showEmptyStateIndicator ? ( + + ) : ( + + {props.loadingMore && } +
+ } + loadNextPage={loadMoreNewer} + loadPreviousPage={loadMore} + threshold={loadMoreScrollThreshold} + {...restInternalInfiniteScrollProps} + > +
    + {elements} +
+ + +
+ + )} +
+
- {!threadList && showUnreadMessagesNotification && ( - - )} -
- > - atBottomStateChange={atBottomStateChange} - atBottomThreshold={100} - atTopStateChange={atTopStateChange} - atTopThreshold={100} - className='str-chat__message-list-scroll' - components={{ - EmptyPlaceholder, - Footer, - Header, - Item, - ...virtuosoComponentsFromProps, - }} - computeItemKey={computeItemKey} - context={{ - additionalMessageInputProps, - closeReactionSelectorOnClick, - customClasses, - customMessageActions, - customMessageRenderer, - DateSeparator, - firstUnreadMessageId: channelUnreadUiState?.first_unread_message_id, - formatDate, - head, - lastReadDate: channelUnreadUiState?.last_read, - lastReadMessageId: channelUnreadUiState?.last_read_message_id, - lastReceivedMessageId, - loadingMore, - Message: MessageUIComponent, - messageActions, - messageGroupStyles, - MessageSystem, - numItemsPrepended, - ownMessagesReadByOthers, - processedMessages, - reactionDetailsSort, - shouldGroupByUser, - sortReactionDetails, - sortReactions, - threadList, - unreadMessageCount: channelUnreadUiState?.unread_messages, - UnreadMessagesSeparator, - virtuosoRef: virtuoso, - }} - firstItemIndex={calculateFirstItemIndex(numItemsPrepended)} - followOutput={followOutput} - increaseViewportBy={{ bottom: 200, top: 0 }} - initialTopMostItemIndex={calculateInitialTopMostItemIndex( - processedMessages, - highlightedMessageId, - )} - itemContent={messageRenderer} - itemSize={fractionalItemSize} - itemsRendered={handleItemsRendered} - key={messageSetKey} - overscan={overscan} - ref={virtuoso} - style={{ overflowX: 'hidden' }} - totalCount={processedMessages.length} - {...overridingVirtuosoProps} - {...(scrollSeekPlaceHolder ? { scrollSeek: scrollSeekPlaceHolder } : {})} - {...(defaultItemHeight ? { defaultItemHeight } : {})} - /> -
+ + {!threadList && showUnreadMessagesNotification && ( + + )} +
+ > + atBottomStateChange={atBottomStateChange} + atBottomThreshold={100} + atTopStateChange={atTopStateChange} + atTopThreshold={100} + className='str-chat__message-list-scroll' + components={{ + EmptyPlaceholder, + Footer, + Header, + Item, + ...virtuosoComponentsFromProps, + }} + computeItemKey={computeItemKey} + context={{ + additionalMessageInputProps, + closeReactionSelectorOnClick, + customClasses, + customMessageActions, + customMessageRenderer, + DateSeparator, + firstUnreadMessageId: channelUnreadUiState?.first_unread_message_id, + formatDate, + head, + lastReadDate: channelUnreadUiState?.last_read, + lastReadMessageId: channelUnreadUiState?.last_read_message_id, + lastReceivedMessageId, + loadingMore, + Message: MessageUIComponent, + messageActions, + messageGroupStyles, + MessageSystem, + numItemsPrepended, + ownMessagesReadByOthers, + processedMessages, + reactionDetailsSort, + shouldGroupByUser, + sortReactionDetails, + sortReactions, + threadList, + unreadMessageCount: channelUnreadUiState?.unread_messages, + UnreadMessagesSeparator, + virtuosoRef: virtuoso, + }} + firstItemIndex={calculateFirstItemIndex(numItemsPrepended)} + followOutput={followOutput} + increaseViewportBy={{ bottom: 200, top: 0 }} + initialTopMostItemIndex={calculateInitialTopMostItemIndex( + processedMessages, + highlightedMessageId, + )} + itemContent={messageRenderer} + itemSize={fractionalItemSize} + itemsRendered={handleItemsRendered} + key={messageSetKey} + overscan={overscan} + ref={virtuoso} + style={{ overflowX: 'hidden' }} + totalCount={processedMessages.length} + {...overridingVirtuosoProps} + {...(scrollSeekPlaceHolder ? { scrollSeek: scrollSeekPlaceHolder } : {})} + {...(defaultItemHeight ? { defaultItemHeight } : {})} + /> +
+
Date: Thu, 5 Sep 2024 15:03:52 +0200 Subject: [PATCH 05/29] fix: do not forward prop mine to MessageActionsBox root div --- src/components/MessageActions/MessageActionsBox.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/MessageActions/MessageActionsBox.tsx b/src/components/MessageActions/MessageActionsBox.tsx index fdbee888d..e7deb75ec 100644 --- a/src/components/MessageActions/MessageActionsBox.tsx +++ b/src/components/MessageActions/MessageActionsBox.tsx @@ -47,6 +47,7 @@ const UnMemoizedMessageActionsBox = < handleMute, handlePin, isUserMuted, + mine, open, ...restDivProps } = props; From 223ddeae0ed6e6bc67ce1cdaccc17d687bb61285 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Sep 2024 15:06:11 +0200 Subject: [PATCH 06/29] test: fix MessageActions tests --- .../__tests__/MessageActions.test.js | 278 +++++++++++------- .../__tests__/MessageActionsBox.test.js | 128 ++++---- 2 files changed, 250 insertions(+), 156 deletions(-) diff --git a/src/components/MessageActions/__tests__/MessageActions.test.js b/src/components/MessageActions/__tests__/MessageActions.test.js index 9c7afa2d3..cbb350f20 100644 --- a/src/components/MessageActions/__tests__/MessageActions.test.js +++ b/src/components/MessageActions/__tests__/MessageActions.test.js @@ -1,15 +1,19 @@ import React from 'react'; import '@testing-library/jest-dom'; import testRenderer from 'react-test-renderer'; -import { cleanup, fireEvent, render } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { MessageActions } from '../MessageActions'; import { MessageActionsBox as MessageActionsBoxMock } from '../MessageActionsBox'; -import { ChannelStateProvider } from '../../../context/ChannelStateContext'; -import { ChatProvider } from '../../../context/ChatContext'; -import { MessageProvider } from '../../../context/MessageContext'; -import { TranslationProvider } from '../../../context/TranslationContext'; +import { + ChannelStateProvider, + ChatProvider, + ComponentProvider, + DialogsManagerProvider, + MessageProvider, + TranslationProvider, +} from '../../../context'; import { generateMessage, getTestClient, mockTranslationContext } from '../../../mock-builders'; @@ -45,17 +49,22 @@ const chatClient = getTestClient(); function renderMessageActions(customProps, renderer = render) { return renderer( - - - - - - - + + + + + + + + + + + , ); } +const dialogOverlayTestId = 'str-chat__dialog-overlay'; const messageActionsTestId = 'message-actions'; describe(' component', () => { afterEach(cleanup); @@ -64,32 +73,44 @@ describe(' component', () => { it('should render correctly', () => { const tree = renderMessageActions({}, testRenderer.create); expect(tree.toJSON()).toMatchInlineSnapshot(` -
-
- -
+ + + + +
, +
, + ] `); }); @@ -101,77 +122,98 @@ describe(' component', () => { expect(queryByTestId(messageActionsTestId)).toBeNull(); }); - it('should open message actions box on click', () => { + it('should open message actions box on click', async () => { const { getByTestId } = renderMessageActions(); expect(MessageActionsBoxMock).toHaveBeenCalledWith( expect.objectContaining({ open: false }), {}, ); - fireEvent.click(getByTestId(messageActionsTestId)); + const dialogOverlay = screen.getByTestId(dialogOverlayTestId); + expect(dialogOverlay.children).toHaveLength(1); + await act(async () => { + await fireEvent.click(getByTestId(messageActionsTestId)); + }); expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( expect.objectContaining({ open: true }), {}, ); + expect(dialogOverlay.children).toHaveLength(1); }); - it('should close message actions box on icon click if already opened', () => { + it('should close message actions box on icon click if already opened', async () => { const { getByTestId } = renderMessageActions(); expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( expect.objectContaining({ open: false }), {}, ); - fireEvent.click(getByTestId(messageActionsTestId)); + await act(async () => { + await fireEvent.click(getByTestId(messageActionsTestId)); + }); expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( expect.objectContaining({ open: true }), {}, ); - fireEvent.click(getByTestId(messageActionsTestId)); + await act(async () => { + await fireEvent.click(getByTestId(messageActionsTestId)); + }); expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( expect.objectContaining({ open: false }), {}, ); }); - it('should close message actions box when user clicks anywhere in the document if it is already opened', () => { + it('should close message actions box when user clicks overlay if it is already opened', async () => { const { getByRole } = renderMessageActions(); - fireEvent.click(getByRole('button')); - + await act(async () => { + await fireEvent.click(getByRole('button')); + }); expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( expect.objectContaining({ open: true }), {}, ); - fireEvent.click(document); + const dialogOverlay = screen.getByTestId(dialogOverlayTestId); + await act(async () => { + await fireEvent.click(dialogOverlay); + }); expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( expect.objectContaining({ open: false }), {}, ); }); - it('should close message actions box when user presses Escape key', () => { + it('should close message actions box when user presses Escape key', async () => { const { getByRole } = renderMessageActions(); - fireEvent.click(getByRole('button')); + await act(async () => { + await fireEvent.click(getByRole('button')); + }); expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( expect.objectContaining({ open: true }), {}, ); - fireEvent.keyUp(document, { charCode: 27, code: 'Escape', key: 'Escape' }); + await act(async () => { + await fireEvent.keyUp(document, { charCode: 27, code: 'Escape', key: 'Escape' }); + }); expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( expect.objectContaining({ open: false }), {}, ); }); - it('should close actions box open on mouseleave if container ref provided', () => { + it('should close actions box open on mouseleave if container ref provided', async () => { const customProps = { messageWrapperRef: { current: wrapperMock }, }; const { getByRole } = renderMessageActions(customProps); - fireEvent.click(getByRole('button')); + await act(async () => { + await fireEvent.click(getByRole('button')); + }); expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( expect.objectContaining({ open: true }), {}, ); - fireEvent.mouseLeave(customProps.messageWrapperRef.current); + await act(async () => { + await fireEvent.mouseLeave(customProps.messageWrapperRef.current); + }); expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( expect.objectContaining({ open: false }), {}, @@ -196,12 +238,13 @@ describe(' component', () => { ); }); - it('should not register click and keyup event listeners to close actions box until opened', () => { + it('should not register click and keyup event listeners to close actions box until opened', async () => { const { getByRole } = renderMessageActions(); const addEventListener = jest.spyOn(document, 'addEventListener'); expect(document.addEventListener).not.toHaveBeenCalled(); - fireEvent.click(getByRole('button')); - expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); + await act(async () => { + await fireEvent.click(getByRole('button')); + }); expect(document.addEventListener).toHaveBeenCalledWith('keyup', expect.any(Function)); addEventListener.mockClear(); }); @@ -216,13 +259,14 @@ describe(' component', () => { removeEventListener.mockClear(); }); - it('should remove event listener when unmounted', () => { + it('should remove event listener when unmounted', async () => { const { getByRole, unmount } = renderMessageActions(); const removeEventListener = jest.spyOn(document, 'removeEventListener'); - fireEvent.click(getByRole('button')); + await act(async () => { + await fireEvent.click(getByRole('button')); + }); expect(document.removeEventListener).not.toHaveBeenCalled(); unmount(); - expect(document.removeEventListener).toHaveBeenCalledWith('click', expect.any(Function)); expect(document.removeEventListener).toHaveBeenCalledWith('keyup', expect.any(Function)); removeEventListener.mockClear(); }); @@ -235,32 +279,44 @@ describe(' component', () => { testRenderer.create, ); expect(tree.toJSON()).toMatchInlineSnapshot(` -
-
- -
+ + + + +
, +
, + ] `); }); @@ -272,32 +328,44 @@ describe(' component', () => { testRenderer.create, ); expect(tree.toJSON()).toMatchInlineSnapshot(` - -
- - + + + + + , +
, + ] `); }); }); diff --git a/src/components/MessageActions/__tests__/MessageActionsBox.test.js b/src/components/MessageActions/__tests__/MessageActionsBox.test.js index 2f786facf..4975bd3e4 100644 --- a/src/components/MessageActions/__tests__/MessageActionsBox.test.js +++ b/src/components/MessageActions/__tests__/MessageActionsBox.test.js @@ -18,7 +18,7 @@ import { import { Message } from '../../Message'; import { Channel } from '../../Channel'; import { Chat } from '../../Chat'; -import { ChatProvider } from '../../../context'; +import { ChatProvider, ComponentProvider, DialogsManagerProvider } from '../../../context'; expect.extend(toHaveNoViolations); @@ -34,19 +34,27 @@ async function renderComponent(boxProps, messageContext = {}) { return render( key }}> - - + - - - + + + + + + + , ); @@ -72,7 +80,9 @@ describe('MessageActionsBox', () => { getMessageActionsMock.mockImplementationOnce(() => ['flag']); const handleFlag = jest.fn(); const { container, getByText } = await renderComponent({ handleFlag }); - fireEvent.click(getByText('Flag')); + await act(async () => { + await fireEvent.click(getByText('Flag')); + }); expect(handleFlag).toHaveBeenCalledTimes(1); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -85,7 +95,9 @@ describe('MessageActionsBox', () => { handleMute, isUserMuted: () => false, }); - fireEvent.click(getByText('Mute')); + await act(async () => { + await fireEvent.click(getByText('Mute')); + }); expect(handleMute).toHaveBeenCalledTimes(1); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -98,7 +110,9 @@ describe('MessageActionsBox', () => { handleMute, isUserMuted: () => true, }); - fireEvent.click(getByText('Unmute')); + await act(async () => { + await fireEvent.click(getByText('Unmute')); + }); expect(handleMute).toHaveBeenCalledTimes(1); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -108,7 +122,9 @@ describe('MessageActionsBox', () => { getMessageActionsMock.mockImplementationOnce(() => ['edit']); const handleEdit = jest.fn(); const { container, getByText } = await renderComponent({ handleEdit }); - fireEvent.click(getByText('Edit Message')); + await act(async () => { + await fireEvent.click(getByText('Edit Message')); + }); expect(handleEdit).toHaveBeenCalledTimes(1); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -118,7 +134,9 @@ describe('MessageActionsBox', () => { getMessageActionsMock.mockImplementationOnce(() => ['delete']); const handleDelete = jest.fn(); const { container, getByText } = await renderComponent({ handleDelete }); - fireEvent.click(getByText('Delete')); + await act(async () => { + await fireEvent.click(getByText('Delete')); + }); expect(handleDelete).toHaveBeenCalledTimes(1); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -129,7 +147,9 @@ describe('MessageActionsBox', () => { const handlePin = jest.fn(); const message = generateMessage({ pinned: false }); const { container, getByText } = await renderComponent({ handlePin, message }); - fireEvent.click(getByText('Pin')); + await act(async () => { + await fireEvent.click(getByText('Pin')); + }); expect(handlePin).toHaveBeenCalledTimes(1); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -140,7 +160,9 @@ describe('MessageActionsBox', () => { const handlePin = jest.fn(); const message = generateMessage({ pinned: true }); const { container, getByText } = await renderComponent({ handlePin, message }); - fireEvent.click(getByText('Unpin')); + await act(async () => { + await fireEvent.click(getByText('Unpin')); + }); expect(handlePin).toHaveBeenCalledTimes(1); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -195,16 +217,18 @@ describe('MessageActionsBox', () => { 'upload-file', ]; const renderMarkUnreadUI = async ({ channelProps, chatProps, messageProps }) => - await act(() => { - render( + await act(async () => { + await render( - + + + , ); @@ -230,8 +254,8 @@ describe('MessageActionsBox', () => { chatProps: { client }, messageProps: { message }, }); - await act(() => { - fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); + await act(async () => { + await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); }); expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument(); }); @@ -257,8 +281,8 @@ describe('MessageActionsBox', () => { chatProps: { client }, messageProps: { message: myMessage }, }); - await act(() => { - fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); + await act(async () => { + await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); }); expect(screen.queryByText(ACTION_TEXT)).toBeInTheDocument(); }); @@ -277,8 +301,8 @@ describe('MessageActionsBox', () => { chatProps: { client }, messageProps: { message, threadList: true }, }); - await act(() => { - fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); + await act(async () => { + await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); }); expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument(); }); @@ -312,8 +336,8 @@ describe('MessageActionsBox', () => { }); }); - await act(() => { - fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); + await act(async () => { + await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); }); expect(screen.queryByText(ACTION_TEXT)).toBeInTheDocument(); }); @@ -341,8 +365,8 @@ describe('MessageActionsBox', () => { chatProps: { client }, messageProps: { message: messageWithoutID }, }); - await act(() => { - fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); + await act(async () => { + await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); }); expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument(); }); @@ -374,12 +398,14 @@ describe('MessageActionsBox', () => { customUser: me, }); - await act(() => { - render( + await act(async () => { + await render( - - + + + + , ); @@ -405,9 +431,9 @@ describe('MessageActionsBox', () => { chatProps: { client }, messageProps: { message }, }); - await act(() => { - fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); - fireEvent.click(screen.getByText(ACTION_TEXT)); + await act(async () => { + await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); + await fireEvent.click(screen.getByText(ACTION_TEXT)); }); expect(channel.markUnread).toHaveBeenCalledWith( expect.objectContaining({ message_id: message.id }), @@ -430,9 +456,9 @@ describe('MessageActionsBox', () => { chatProps: { client }, messageProps: { getMarkMessageUnreadSuccessNotification, message }, }); - await act(() => { - fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); - fireEvent.click(screen.getByText(ACTION_TEXT)); + await act(async () => { + await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); + await fireEvent.click(screen.getByText(ACTION_TEXT)); }); expect(getMarkMessageUnreadSuccessNotification).toHaveBeenCalledWith( expect.objectContaining(message), @@ -455,9 +481,9 @@ describe('MessageActionsBox', () => { chatProps: { client }, messageProps: { getMarkMessageUnreadErrorNotification, message }, }); - await act(() => { - fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); - fireEvent.click(screen.getByText(ACTION_TEXT)); + await act(async () => { + await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); + await fireEvent.click(screen.getByText(ACTION_TEXT)); }); expect(getMarkMessageUnreadErrorNotification).toHaveBeenCalledWith( expect.objectContaining(message), From d45459b7672f59e2135a8d578a51c18a10d4634b Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Sep 2024 15:38:06 +0200 Subject: [PATCH 07/29] test: fix tests rendering message actions --- .../Message/__tests__/MessageOptions.test.js | 49 ++++---- .../MessageList/VirtualizedMessageList.tsx | 2 +- .../VirtualizedMessageListComponents.test.js | 108 ++++++++++++++++-- .../VirtualizedMessageList.test.js.snap | 11 ++ ...tualizedMessageListComponents.test.js.snap | 54 +++++++++ 5 files changed, 192 insertions(+), 32 deletions(-) diff --git a/src/components/Message/__tests__/MessageOptions.test.js b/src/components/Message/__tests__/MessageOptions.test.js index fb3ce9b8a..4a6571980 100644 --- a/src/components/Message/__tests__/MessageOptions.test.js +++ b/src/components/Message/__tests__/MessageOptions.test.js @@ -21,6 +21,7 @@ import { generateUser, getTestClientWithUser, } from '../../../mock-builders'; +import { DialogsManagerProvider } from '../../../context'; const MESSAGE_ACTIONS_TEST_ID = 'message-actions'; @@ -55,32 +56,34 @@ async function renderMessageOptions({ return render( - - - + + ( - - ), + openThread: jest.fn(), + removeMessage: jest.fn(), + updateMessage: jest.fn(), }} > - - - - - - + ( + + ), + }} + > + + + + + + + , ); } diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index 89a556623..1b2b7157c 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -428,7 +428,7 @@ const VirtualizedMessageListWithContext = < return ( <> - + {!threadList && showUnreadMessagesNotification && ( )} diff --git a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js index 9230ade0a..b900ecf85 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js +++ b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js @@ -20,6 +20,7 @@ import { ChannelStateProvider, ChatProvider, ComponentProvider, + DialogsManagerProvider, TranslationProvider, useMessageContext, } from '../../../context'; @@ -38,7 +39,11 @@ const Wrapper = ({ children, componentContext = {} }) => ( - {children} + + + {children} + + @@ -84,7 +89,16 @@ describe('VirtualizedMessageComponents', () => { const CustomLoadingIndicator = () =>
Custom Loading Indicator
; it('should render empty div in Header when not loading more messages', () => { const { container } = renderElements(
); - expect(container).toMatchInlineSnapshot(`
`); + expect(container).toMatchInlineSnapshot(` +
+
+
+ `); }); it('should render LoadingIndicator in Header when loading more messages', () => { @@ -106,6 +120,12 @@ describe('VirtualizedMessageComponents', () => { Custom Loading Indicator
+
`); }); @@ -113,7 +133,16 @@ describe('VirtualizedMessageComponents', () => { it('should not render custom LoadingIndicator in Header when not loading more messages', () => { const componentContext = { LoadingIndicator: CustomLoadingIndicator }; const { container } = renderElements(
, componentContext); - expect(container).toMatchInlineSnapshot(`
`); + expect(container).toMatchInlineSnapshot(` +
+
+
+ `); }); // FIXME: this is a crazy pattern of having to set LoadingIndicator to null so that additionalVirtuosoProps.head can be rendered. @@ -135,6 +164,12 @@ describe('VirtualizedMessageComponents', () => {
Custom head
+
`); }); @@ -142,7 +177,16 @@ describe('VirtualizedMessageComponents', () => { it('should not render custom head in Header when not loading more messages', () => { const context = { head }; const { container } = renderElements(
); - expect(container).toMatchInlineSnapshot(`
`); + expect(container).toMatchInlineSnapshot(` +
+
+
+ `); }); it('should render custom LoadingIndicator instead of head when loading more', () => { @@ -158,6 +202,12 @@ describe('VirtualizedMessageComponents', () => { Custom Loading Indicator
+
`); }); @@ -176,7 +226,16 @@ describe('VirtualizedMessageComponents', () => { it('should render empty for thread by default', () => { const { container } = renderElements(); - expect(container).toMatchInlineSnapshot(`
`); + expect(container).toMatchInlineSnapshot(` +
+
+
+ `); }); it('should render custom EmptyStateIndicator for main message list', () => { const { container } = renderElements(, componentContext); @@ -194,7 +253,16 @@ describe('VirtualizedMessageComponents', () => { it('should render empty if EmptyStateIndicator nullified', () => { const componentContext = { EmptyStateIndicator: NullEmptyStateIndicator }; const { container } = renderElements(, componentContext); - expect(container).toMatchInlineSnapshot(`
`); + expect(container).toMatchInlineSnapshot(` +
+
+
+ `); }); it('should render empty in thread if EmptyStateIndicator nullified', () => { @@ -203,14 +271,32 @@ describe('VirtualizedMessageComponents', () => { , componentContext, ); - expect(container).toMatchInlineSnapshot(`
`); + expect(container).toMatchInlineSnapshot(` +
+
+
+ `); }); }); describe('Footer', () => { it('should render nothing in Footer by default', () => { const { container } = renderElements(
); - expect(container).toMatchInlineSnapshot(`
`); + expect(container).toMatchInlineSnapshot(` +
+
+
+ `); }); it('should render custom TypingIndicator in Footer', () => { const TypingIndicator = () =>
Custom TypingIndicator
; @@ -221,6 +307,12 @@ describe('VirtualizedMessageComponents', () => {
Custom TypingIndicator
+
`); }); diff --git a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap index 4ea760c01..052d5d589 100644 --- a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap +++ b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap @@ -65,6 +65,17 @@ exports[`VirtualizedMessageList should render the list without any message 1`] =
+
diff --git a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageListComponents.test.js.snap b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageListComponents.test.js.snap index 47619c06a..0ab1b8f65 100644 --- a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageListComponents.test.js.snap +++ b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageListComponents.test.js.snap @@ -7,6 +7,12 @@ exports[`VirtualizedMessageComponents EmptyPlaceholder should render custom Empt > Custom EmptyStateIndicator
+
`; @@ -17,6 +23,12 @@ exports[`VirtualizedMessageComponents EmptyPlaceholder should render custom Empt > Custom EmptyStateIndicator
+
`; @@ -45,6 +57,12 @@ exports[`VirtualizedMessageComponents EmptyPlaceholder should render for main me No chats here yet…

+
`; @@ -94,6 +112,12 @@ exports[`VirtualizedMessageComponents Header should not render custom head in He
+
`; @@ -143,6 +167,12 @@ exports[`VirtualizedMessageComponents Header should render LoadingIndicator in H
+
`; @@ -152,6 +182,12 @@ exports[`VirtualizedMessageComponents Item should render wrapper with custom cla class="XXX" data-item-index="10000000" /> +
`; @@ -161,6 +197,12 @@ exports[`VirtualizedMessageComponents Item should render wrapper with custom cla class="XXX" data-item-index="10000000" /> +
`; @@ -170,6 +212,12 @@ exports[`VirtualizedMessageComponents Item should render wrapper without custom class="str-chat__virtual-list-message-wrapper str-chat__li str-chat__li--single" data-item-index="10000000" /> +
`; @@ -179,5 +227,11 @@ exports[`VirtualizedMessageComponents Item should render wrapper without custom class="str-chat__virtual-list-message-wrapper str-chat__li" data-item-index="10000000" /> +
`; From ba89493d826d05fa292a9f8f2ef139698ad75410 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Sep 2024 15:42:10 +0200 Subject: [PATCH 08/29] feat: assign static id to DialogsManager inside MessageList --- src/components/MessageList/MessageList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 785982d27..0d25667a5 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -226,7 +226,7 @@ const MessageListWithContext = < return ( - + {!threadList && showUnreadMessagesNotification && ( )} From 993358f67ba83046e221b92270b47cd56d93ee9a Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Sep 2024 16:05:57 +0200 Subject: [PATCH 09/29] docs: fix todo comment --- docusaurus/docs/React/guides/theming/message-ui.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docusaurus/docs/React/guides/theming/message-ui.mdx b/docusaurus/docs/React/guides/theming/message-ui.mdx index 52e092be8..65cfe632a 100644 --- a/docusaurus/docs/React/guides/theming/message-ui.mdx +++ b/docusaurus/docs/React/guides/theming/message-ui.mdx @@ -387,7 +387,7 @@ const CustomMessageUi = () => { Message grouping is being managed automatically by the SDK and each parent element (which holds our message UI) receives an appropriate class name based on which we can adjust our rules to display metadata elements only when it's appropriate to make our UI look less busy. -{/_ TODO: link to grouping logic (maybe how to adjust it if needed) _/} +[//]: # 'TODO: link to grouping logic (maybe how to adjust it if needed)' ```css .custom-message-ui__metadata { From b557e25d39b6a6e96d0124c9f65fd480a23076d7 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Sep 2024 17:13:15 +0200 Subject: [PATCH 10/29] feat: handle focus within dialog --- src/components/Dialog/DialogAnchor.tsx | 48 +++++++++++++++++++ .../MessageActions/MessageActions.tsx | 1 + 2 files changed, 49 insertions(+) diff --git a/src/components/Dialog/DialogAnchor.tsx b/src/components/Dialog/DialogAnchor.tsx index 20c7434f0..8eac6aa75 100644 --- a/src/components/Dialog/DialogAnchor.tsx +++ b/src/components/Dialog/DialogAnchor.tsx @@ -49,14 +49,18 @@ export function useDialogAnchor({ type DialogAnchorProps = PropsWithChildren> & { id: string; + focus?: boolean; + trapFocus?: boolean; } & ComponentProps<'div'>; export const DialogAnchor = ({ children, className, + focus = true, id, placement = 'auto', referenceElement = null, + trapFocus, ...restDivProps }: DialogAnchorProps) => { const open = useDialogIsOpen(id); @@ -66,6 +70,43 @@ export const DialogAnchor = ({ referenceElement, }); + // handle focus and focus trap inside the dialog + useEffect(() => { + if (!popperElementRef.current || !focus || !open) return; + const container = popperElementRef.current; + container.focus(); + + if (!trapFocus) return; + const handleKeyDownWithTabRoundRobin = (event: KeyboardEvent) => { + if (event.key !== 'Tab') return; + + const focusableElements = getFocusableElements(container); + if (focusableElements.length === 0) return; + + const firstElement = focusableElements[0] as HTMLElement; + const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement; + if (firstElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + + // Trap focus within the group + if (event.shiftKey && document.activeElement === firstElement) { + // If Shift + Tab on the first element, move focus to the last element + event.preventDefault(); + lastElement.focus(); + } else if (!event.shiftKey && document.activeElement === lastElement) { + // If Tab on the last element, move focus to the first element + event.preventDefault(); + firstElement.focus(); + } + }; + + container.addEventListener('keydown', handleKeyDownWithTabRoundRobin); + + return () => container.removeEventListener('keydown', handleKeyDownWithTabRoundRobin); + }, [focus, popperElementRef, open, trapFocus]); + return (
{children}
); }; + +function getFocusableElements(container: HTMLElement) { + return container.querySelectorAll( + 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])', + ); +} diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx index 41dd364e7..cdae62daa 100644 --- a/src/components/MessageActions/MessageActions.tsx +++ b/src/components/MessageActions/MessageActions.tsx @@ -149,6 +149,7 @@ export const MessageActions = < id={dialogId} placement={isMine ? 'top-end' : 'top-start'} referenceElement={actionsBoxButtonRef.current} + trapFocus > Date: Fri, 6 Sep 2024 15:52:07 +0200 Subject: [PATCH 11/29] feat: prevent rendering dialog contents if not open --- src/components/Dialog/DialogAnchor.tsx | 98 +++++++++++--------------- 1 file changed, 43 insertions(+), 55 deletions(-) diff --git a/src/components/Dialog/DialogAnchor.tsx b/src/components/Dialog/DialogAnchor.tsx index 8eac6aa75..db27d8ee6 100644 --- a/src/components/Dialog/DialogAnchor.tsx +++ b/src/components/Dialog/DialogAnchor.tsx @@ -1,9 +1,10 @@ import clsx from 'clsx'; import { Placement } from '@popperjs/core'; -import React, { ComponentProps, PropsWithChildren, useEffect, useRef } from 'react'; +import React, { ComponentProps, PropsWithChildren, useEffect, useState } from 'react'; +import { FocusScope } from '@react-aria/focus'; import { usePopper } from 'react-popper'; -import { useDialogIsOpen } from './hooks'; import { DialogPortalEntry } from './DialogPortal'; +import { useDialog, useDialogIsOpen } from './hooks'; export interface DialogAnchorOptions { open: boolean; @@ -16,8 +17,8 @@ export function useDialogAnchor({ placement, referenceElement, }: DialogAnchorOptions) { - const popperElementRef = useRef(null); - const { attributes, styles, update } = usePopper(referenceElement, popperElementRef.current, { + const [popperElement, setPopperElement] = useState(null); + const { attributes, styles, update } = usePopper(referenceElement, popperElement, { modifiers: [ { name: 'eventListeners', @@ -33,16 +34,17 @@ export function useDialogAnchor({ }); useEffect(() => { - if (open) { + if (open && popperElement) { // Since the popper's reference element might not be (and usually is not) visible // all the time, it's safer to force popper update before showing it. + // update is non-null only if popperElement is non-null update?.(); } - }, [open, update]); + }, [open, popperElement, update]); return { attributes, - popperElementRef, + setPopperElement, styles, }; } @@ -63,69 +65,55 @@ export const DialogAnchor = ({ trapFocus, ...restDivProps }: DialogAnchorProps) => { + const dialog = useDialog({ id }); const open = useDialogIsOpen(id); - const { attributes, popperElementRef, styles } = useDialogAnchor({ + const { attributes, setPopperElement, styles } = useDialogAnchor({ open, placement, referenceElement, }); - // handle focus and focus trap inside the dialog useEffect(() => { - if (!popperElementRef.current || !focus || !open) return; - const container = popperElementRef.current; - container.focus(); - - if (!trapFocus) return; - const handleKeyDownWithTabRoundRobin = (event: KeyboardEvent) => { - if (event.key !== 'Tab') return; - - const focusableElements = getFocusableElements(container); - if (focusableElements.length === 0) return; + if (!open) return; + const hideOnEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + dialog?.close(); + }; - const firstElement = focusableElements[0] as HTMLElement; - const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement; - if (firstElement === lastElement) { - event.preventDefault(); - firstElement.focus(); - } + document.addEventListener('keyup', hideOnEscape); - // Trap focus within the group - if (event.shiftKey && document.activeElement === firstElement) { - // If Shift + Tab on the first element, move focus to the last element - event.preventDefault(); - lastElement.focus(); - } else if (!event.shiftKey && document.activeElement === lastElement) { - // If Tab on the last element, move focus to the first element - event.preventDefault(); - firstElement.focus(); - } + return () => { + document.removeEventListener('keyup', hideOnEscape); }; + }, [dialog, open]); - container.addEventListener('keydown', handleKeyDownWithTabRoundRobin); + useEffect(() => { + if (!open) { + // setting element reference back to null allows to re-run the usePopper component once the component is re-rendered + setPopperElement(null); + } + }, [open, setPopperElement]); - return () => container.removeEventListener('keydown', handleKeyDownWithTabRoundRobin); - }, [focus, popperElementRef, open, trapFocus]); + // prevent rendering the dialog contents if the dialog should not be open / shown + if (!open) { + return null; + } return ( -
- {children} -
+ +
+ {children} +
+
); }; - -function getFocusableElements(container: HTMLElement) { - return container.querySelectorAll( - 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])', - ); -} From 07eb2610f8d9ca42de053947736fc959c8bb5cdc Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 6 Sep 2024 16:34:30 +0200 Subject: [PATCH 12/29] refactor: do not register event listeners by MessageActions component --- .../Message/__tests__/MessageOptions.test.js | 4 +- .../Message/__tests__/MessageText.test.js | 1 - .../MessageActions/MessageActions.tsx | 50 +------ .../__tests__/MessageActions.test.js | 127 +++++------------- 4 files changed, 42 insertions(+), 140 deletions(-) diff --git a/src/components/Message/__tests__/MessageOptions.test.js b/src/components/Message/__tests__/MessageOptions.test.js index 4a6571980..8b9309291 100644 --- a/src/components/Message/__tests__/MessageOptions.test.js +++ b/src/components/Message/__tests__/MessageOptions.test.js @@ -34,9 +34,7 @@ const defaultMessageProps = { onReactionListClick: () => {}, threadList: false, }; -const defaultOptionsProps = { - messageWrapperRef: { current: document.createElement('div') }, -}; +const defaultOptionsProps = {}; function generateAliceMessage(messageOptions) { return generateMessage({ diff --git a/src/components/Message/__tests__/MessageText.test.js b/src/components/Message/__tests__/MessageText.test.js index fcac6a4b9..953fca0e8 100644 --- a/src/components/Message/__tests__/MessageText.test.js +++ b/src/components/Message/__tests__/MessageText.test.js @@ -43,7 +43,6 @@ const onMentionsClickMock = jest.fn(); const defaultProps = { initialMessage: false, message: generateMessage(), - messageWrapperRef: { current: document.createElement('div') }, onReactionListClick: () => {}, threadList: false, }; diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx index cdae62daa..1567a9f99 100644 --- a/src/components/MessageActions/MessageActions.tsx +++ b/src/components/MessageActions/MessageActions.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import React, { ElementRef, PropsWithChildren, useCallback, useEffect, useRef } from 'react'; +import React, { ElementRef, PropsWithChildren, useCallback, useRef } from 'react'; import { MessageActionsBox } from './MessageActionsBox'; @@ -31,8 +31,6 @@ export type MessageActionsProps< customWrapperClass?: string; /* If true, renders the wrapper component as a `span`, not a `div` */ inline?: boolean; - /* React mutable ref that can be placed on the message root `div` of MessageActions component */ - messageWrapperRef?: React.RefObject; /* Function that returns whether the message was sent by the connected user */ mine?: () => boolean; }; @@ -53,7 +51,6 @@ export const MessageActions = < handlePin: propHandlePin, inline, message: propMessage, - messageWrapperRef, mine, } = props; @@ -101,50 +98,12 @@ export const MessageActions = < messageActions, }); - const messageDeletedAt = !!message?.deleted_at; - - const hideOptions = useCallback( - (event: MouseEvent | KeyboardEvent) => { - if (event instanceof KeyboardEvent && event.key !== 'Escape') { - return; - } - dialog?.close(); - }, - [dialog], - ); - - useEffect(() => { - if (messageWrapperRef?.current) { - messageWrapperRef.current.addEventListener('mouseleave', hideOptions); - } - }, [hideOptions, messageWrapperRef]); - - useEffect(() => { - if (messageDeletedAt) { - document.removeEventListener('click', hideOptions); - } - }, [hideOptions, messageDeletedAt]); - - useEffect(() => { - if (!dialogIsOpen) return; - - document.addEventListener('keyup', hideOptions); - - return () => { - document.removeEventListener('keyup', hideOptions); - }; - }, [dialog, dialogIsOpen, hideOptions]); - const actionsBoxButtonRef = useRef>(null); if (!renderMessageActions) return null; return ( - + @@ -180,11 +140,10 @@ export const MessageActions = < export type MessageActionsWrapperProps = { customWrapperClass?: string; inline?: boolean; - toggleOpen?: () => void; }; const MessageActionsWrapper = (props: PropsWithChildren) => { - const { children, customWrapperClass, inline, toggleOpen } = props; + const { children, customWrapperClass, inline } = props; const defaultWrapperClass = clsx( 'str-chat__message-simple__actions__action', @@ -195,7 +154,6 @@ const MessageActionsWrapper = (props: PropsWithChildren{children}; diff --git a/src/components/MessageActions/__tests__/MessageActions.test.js b/src/components/MessageActions/__tests__/MessageActions.test.js index cbb350f20..1783b69af 100644 --- a/src/components/MessageActions/__tests__/MessageActions.test.js +++ b/src/components/MessageActions/__tests__/MessageActions.test.js @@ -66,24 +66,30 @@ function renderMessageActions(customProps, renderer = render) { const dialogOverlayTestId = 'str-chat__dialog-overlay'; const messageActionsTestId = 'message-actions'; + +const toggleOpenMessageActions = async () => { + await act(async () => { + await fireEvent.click(screen.getByRole('button')); + }); +}; describe(' component', () => { afterEach(cleanup); beforeEach(jest.clearAllMocks); - it('should render correctly', () => { + it('should render correctly when not open', () => { const tree = renderMessageActions({}, testRenderer.create); expect(tree.toJSON()).toMatchInlineSnapshot(` Array [
+ )}
); diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx index 33a909521..bc678e1a0 100644 --- a/src/components/Message/MessageSimple.tsx +++ b/src/components/Message/MessageSimple.tsx @@ -22,10 +22,7 @@ import { CUSTOM_MESSAGE_TYPE } from '../../constants/messageTypes'; import { EditMessageForm as DefaultEditMessageForm, MessageInput } from '../MessageInput'; import { MML } from '../MML'; import { Modal } from '../Modal'; -import { - ReactionsList as DefaultReactionList, - ReactionSelector as DefaultReactionSelector, -} from '../Reactions'; +import { ReactionsList as DefaultReactionList } from '../Reactions'; import { MessageBounceModal } from '../MessageBounce/MessageBounceModal'; import { useChatContext } from '../../context/ChatContext'; @@ -59,13 +56,10 @@ const MessageSimpleWithContext = < handleRetry, highlighted, isMyMessage, - isReactionEnabled, message, onUserClick, onUserHover, - reactionSelectorRef, renderText, - showDetailedReactions, threadList, } = props; @@ -83,7 +77,7 @@ const MessageSimpleWithContext = < MessageRepliesCountButton = DefaultMessageRepliesCountButton, MessageStatus = DefaultMessageStatus, MessageTimestamp = DefaultMessageTimestamp, - ReactionSelector = DefaultReactionSelector, + ReactionsList = DefaultReactionList, PinIndicator, } = useComponentContext('MessageSimple'); @@ -100,14 +94,6 @@ const MessageSimpleWithContext = < return ; } - /** FIXME: isReactionEnabled should be removed with next major version and a proper centralized permissions logic should be put in place - * With the current permissions implementation it would be sth like: - * const messageActions = getMessageActions(); - * const canReact = messageActions.includes(MESSAGE_ACTIONS.react); - */ - const canReact = isReactionEnabled; - const canShowReactions = hasReactions; - const showMetadata = !groupedByUser || endOfGroup; const showReplyCountButton = !threadList && !!message.reply_count; const allowRetry = message.status === 'failed' && message.errorStatusCode !== 403; @@ -136,7 +122,7 @@ const MessageSimpleWithContext = < 'str-chat__message--has-attachment': hasAttachment, 'str-chat__message--highlighted': highlighted, 'str-chat__message--pinned pinned-message': message.pinned, - 'str-chat__message--with-reactions': canShowReactions, + 'str-chat__message--with-reactions': hasReactions, 'str-chat__message-send-can-be-retried': message?.status === 'failed' && message?.errorStatusCode !== 403, 'str-chat__message-with-thread-link': showReplyCountButton, @@ -190,8 +176,7 @@ const MessageSimpleWithContext = < >
- {canShowReactions && } - {showDetailedReactions && canReact && } + {hasReactions && }
{message.attachments?.length && !message.quoted_message ? ( diff --git a/src/components/Message/__tests__/MessageOptions.test.js b/src/components/Message/__tests__/MessageOptions.test.js index 8b9309291..f3b569c56 100644 --- a/src/components/Message/__tests__/MessageOptions.test.js +++ b/src/components/Message/__tests__/MessageOptions.test.js @@ -1,6 +1,6 @@ /* eslint-disable jest-dom/prefer-to-have-class */ import React from 'react'; -import { fireEvent, render } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import { Message } from '../Message'; @@ -22,6 +22,7 @@ import { getTestClientWithUser, } from '../../../mock-builders'; import { DialogsManagerProvider } from '../../../context'; +import { defaultReactionOptions } from '../../Reactions'; const MESSAGE_ACTIONS_TEST_ID = 'message-actions'; @@ -73,6 +74,7 @@ async function renderMessageOptions({ onReactionListClick={customMessageProps?.onReactionListClick} /> ), + reactionOptions: defaultReactionOptions, }} > @@ -182,6 +184,85 @@ describe('', () => { expect(queryByTestId(reactionActionTestId)).not.toBeInTheDocument(); }); + it('should not render ReactionsSelector until open', async () => { + const { queryByTestId } = await renderMessageOptions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + }); + expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument(); + await act(async () => { + await fireEvent.click(queryByTestId(reactionActionTestId)); + }); + expect(screen.getByTestId('reaction-selector')).toBeInTheDocument(); + }); + + it('should unmount ReactionsSelector when closed by click on dialog overlay', async () => { + const { queryByTestId } = await renderMessageOptions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + }); + await act(async () => { + await fireEvent.click(queryByTestId(reactionActionTestId)); + }); + await act(async () => { + await fireEvent.click(screen.getByTestId('str-chat__dialog-overlay')); + }); + expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument(); + }); + + it('should unmount ReactionsSelector when closed pressed Esc button', async () => { + const { queryByTestId } = await renderMessageOptions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + }); + await act(async () => { + await fireEvent.click(queryByTestId(reactionActionTestId)); + }); + await act(async () => { + await fireEvent.keyUp(document, { charCode: 27, code: 'Escape', key: 'Escape' }); + }); + expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument(); + }); + + it('should unmount ReactionsSelector when closed on reaction selection and closeReactionSelectorOnClick enabled', async () => { + const { queryByTestId } = await renderMessageOptions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + customMessageProps: { + closeReactionSelectorOnClick: true, + }, + }); + await act(async () => { + await fireEvent.click(queryByTestId(reactionActionTestId)); + }); + await act(async () => { + await fireEvent.click(screen.queryAllByTestId('select-reaction-button')[0]); + }); + expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument(); + }); + + it('should not unmount ReactionsSelector when closed on reaction selection and closeReactionSelectorOnClick enabled', async () => { + const { queryByTestId } = await renderMessageOptions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + customMessageProps: { + closeReactionSelectorOnClick: false, + }, + }); + await act(async () => { + await fireEvent.click(queryByTestId(reactionActionTestId)); + }); + await act(async () => { + await fireEvent.click(screen.queryAllByTestId('select-reaction-button')[0]); + }); + expect(screen.queryByTestId('reaction-selector')).toBeInTheDocument(); + }); + it('should render message actions', async () => { const { queryByTestId } = await renderMessageOptions({ channelStateOpts: { channelCapabilities: minimumCapabilitiesToRenderMessageActions }, diff --git a/src/components/Message/__tests__/QuotedMessage.test.js b/src/components/Message/__tests__/QuotedMessage.test.js index 67c9cf6ae..b076be757 100644 --- a/src/components/Message/__tests__/QuotedMessage.test.js +++ b/src/components/Message/__tests__/QuotedMessage.test.js @@ -9,6 +9,7 @@ import { ChannelStateProvider, ChatProvider, ComponentProvider, + DialogsManagerProvider, TranslationProvider, } from '../../../context'; import { @@ -65,9 +66,11 @@ async function renderQuotedMessage(customProps) { Message: () => , }} > - - - + + + + + diff --git a/src/components/Message/hooks/__tests__/useReactionHandler.test.js b/src/components/Message/hooks/__tests__/useReactionHandler.test.js index 04a03f1c4..3b61291ff 100644 --- a/src/components/Message/hooks/__tests__/useReactionHandler.test.js +++ b/src/components/Message/hooks/__tests__/useReactionHandler.test.js @@ -1,11 +1,7 @@ import React from 'react'; -import { act, renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-hooks'; -import { - reactionHandlerWarning, - useReactionClick, - useReactionHandler, -} from '../useReactionHandler'; +import { reactionHandlerWarning, useReactionHandler } from '../useReactionHandler'; import { ChannelActionProvider } from '../../../../context/ChannelActionContext'; import { ChannelStateProvider } from '../../../../context/ChannelStateContext'; @@ -123,192 +119,3 @@ describe('useReactionHandler custom hook', () => { expect(updateMessage).toHaveBeenCalledWith(message); }); }); - -function renderUseReactionClickHook( - message = generateMessage(), - reactionListRef = React.createRef(), - messageWrapperRef = React.createRef(), -) { - const channel = generateChannel(); - - const wrapper = ({ children }) => ( - - {children} - - ); - - const { rerender, result } = renderHook( - () => useReactionClick(message, reactionListRef, messageWrapperRef), - { wrapper }, - ); - return { rerender, result }; -} - -describe('useReactionClick custom hook', () => { - beforeEach(jest.clearAllMocks); - it('should initialize a click handler and a flag for showing detailed reactions', () => { - const { - result: { current }, - } = renderUseReactionClickHook(); - - expect(typeof current.onReactionListClick).toBe('function'); - expect(current.showDetailedReactions).toBe(false); - }); - - it('should set show details to true on click', async () => { - const { result } = renderUseReactionClickHook(); - expect(result.current.showDetailedReactions).toBe(false); - await act(() => { - result.current.onReactionListClick(); - }); - expect(result.current.showDetailedReactions).toBe(true); - }); - - it('should return correct value for isReactionEnabled', () => { - const channel = generateChannel(); - const channelCapabilities = { 'send-reaction': true }; - - const { rerender, result } = renderHook( - () => useReactionClick(generateMessage(), React.createRef(), React.createRef()), - { - // eslint-disable-next-line react/display-name - wrapper: ({ children }) => ( - - {children} - - ), - }, - ); - - expect(result.current.isReactionEnabled).toBe(true); - channelCapabilities['send-reaction'] = false; - rerender(); - expect(result.current.isReactionEnabled).toBe(false); - channelCapabilities['send-reaction'] = true; - rerender(); - expect(result.current.isReactionEnabled).toBe(true); - }); - - it('should set event listener to close reaction list on document click when list is opened', async () => { - const clickMock = { - target: document.createElement('div'), - }; - const { result } = renderUseReactionClickHook(); - let onDocumentClick; - const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation( - jest.fn((_, fn) => { - onDocumentClick = fn; - }), - ); - await act(() => { - result.current.onReactionListClick(); - }); - expect(result.current.showDetailedReactions).toBe(true); - expect(document.addEventListener).toHaveBeenCalledTimes(1); - expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); - await act(() => { - onDocumentClick(clickMock); - }); - expect(result.current.showDetailedReactions).toBe(false); - addEventListenerSpy.mockRestore(); - }); - - it('should set event listener to message wrapper reference when one is set', async () => { - const mockMessageWrapperReference = { - current: { - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - }, - }; - const { result } = renderUseReactionClickHook( - generateMessage(), - React.createRef(), - mockMessageWrapperReference, - ); - await act(() => { - result.current.onReactionListClick(); - }); - expect(mockMessageWrapperReference.current.addEventListener).toHaveBeenCalledWith( - 'mouseleave', - expect.any(Function), - ); - }); - - it('should not close reaction list on document click when click is on the reaction list itself', async () => { - const message = generateMessage(); - const reactionSelectorEl = document.createElement('div'); - const reactionListElement = document.createElement('div').appendChild(reactionSelectorEl); - const clickMock = { - target: reactionSelectorEl, - }; - const { result } = renderUseReactionClickHook(message, { - current: reactionListElement, - }); - let onDocumentClick; - const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation( - jest.fn((_, fn) => { - onDocumentClick = fn; - }), - ); - await act(() => { - result.current.onReactionListClick(); - }); - expect(result.current.showDetailedReactions).toBe(true); - await act(() => { - onDocumentClick(clickMock); - }); - expect(result.current.showDetailedReactions).toBe(true); - addEventListenerSpy.mockRestore(); - }); - - it('should remove close click event listeners after reaction list is closed', async () => { - const clickMock = { - target: document.createElement('div'), - }; - const { result } = renderUseReactionClickHook(); - let onDocumentClick; - const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation( - jest.fn((_, fn) => { - onDocumentClick = fn; - }), - ); - const removeEventListenerSpy = jest - .spyOn(document, 'removeEventListener') - .mockImplementationOnce(jest.fn()); - await act(() => { - result.current.onReactionListClick(); - }); - expect(result.current.showDetailedReactions).toBe(true); - act(() => onDocumentClick(clickMock)); - expect(result.current.showDetailedReactions).toBe(false); - expect(document.removeEventListener).toHaveBeenCalledWith('click', onDocumentClick); - addEventListenerSpy.mockRestore(); - removeEventListenerSpy.mockRestore(); - }); - - it('should remove close click event listeners if message is deleted', async () => { - const clickMock = { - target: document.createElement('div'), - }; - const message = generateMessage(); - let onDocumentClick; - const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation( - jest.fn((_, fn) => { - onDocumentClick = fn; - }), - ); - const removeEventListenerSpy = jest - .spyOn(document, 'removeEventListener') - .mockImplementationOnce(jest.fn()); - const { rerender, result } = renderUseReactionClickHook(message); - expect(document.removeEventListener).not.toHaveBeenCalled(); - await act(() => { - result.current.onReactionListClick(clickMock); - }); - message.deleted_at = new Date(); - rerender(); - expect(document.removeEventListener).toHaveBeenCalledWith('click', onDocumentClick); - addEventListenerSpy.mockRestore(); - removeEventListenerSpy.mockRestore(); - }); -}); diff --git a/src/components/Message/hooks/useReactionHandler.ts b/src/components/Message/hooks/useReactionHandler.ts index b7275801d..2565d00bb 100644 --- a/src/components/Message/hooks/useReactionHandler.ts +++ b/src/components/Message/hooks/useReactionHandler.ts @@ -1,12 +1,10 @@ -import React, { RefObject, useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback } from 'react'; import throttle from 'lodash.throttle'; import { useChannelActionContext } from '../../../context/ChannelActionContext'; import { StreamMessage, useChannelStateContext } from '../../../context/ChannelStateContext'; import { useChatContext } from '../../../context/ChatContext'; -import type { ReactEventHandler } from '../types'; - import type { Reaction, ReactionResponse } from 'stream-chat'; import type { DefaultStreamChatGenerics } from '../../../types/types'; @@ -141,93 +139,3 @@ export const useReactionHandler = < } }; }; - -export const useReactionClick = < - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics ->( - message?: StreamMessage, - reactionSelectorRef?: RefObject, - messageWrapperRef?: RefObject, - closeReactionSelectorOnClick?: boolean, -) => { - const { channelCapabilities = {} } = useChannelStateContext( - 'useReactionClick', - ); - - const [showDetailedReactions, setShowDetailedReactions] = useState(false); - - const hasListener = useRef(false); - - const isReactionEnabled = channelCapabilities['send-reaction']; - - const messageDeleted = !!message?.deleted_at; - - const closeDetailedReactions: EventListener = useCallback( - (event) => { - if ( - event.target instanceof HTMLElement && - reactionSelectorRef?.current?.contains(event.target) && - !closeReactionSelectorOnClick - ) { - return; - } - - setShowDetailedReactions(false); - }, - [closeReactionSelectorOnClick, setShowDetailedReactions, reactionSelectorRef], - ); - - useEffect(() => { - const messageWrapper = messageWrapperRef?.current; - - if (showDetailedReactions && !hasListener.current) { - hasListener.current = true; - document.addEventListener('click', closeDetailedReactions); - messageWrapper?.addEventListener('mouseleave', closeDetailedReactions); - } - - if (!showDetailedReactions && hasListener.current) { - document.removeEventListener('click', closeDetailedReactions); - messageWrapper?.removeEventListener('mouseleave', closeDetailedReactions); - - hasListener.current = false; - } - - return () => { - if (hasListener.current) { - document.removeEventListener('click', closeDetailedReactions); - - if (messageWrapper) { - messageWrapper.removeEventListener('mouseleave', closeDetailedReactions); - } - - hasListener.current = false; - } - }; - }, [showDetailedReactions, closeDetailedReactions, messageWrapperRef]); - - useEffect(() => { - const messageWrapper = messageWrapperRef?.current; - - if (messageDeleted && hasListener.current) { - document.removeEventListener('click', closeDetailedReactions); - - if (messageWrapper) { - messageWrapper.removeEventListener('mouseleave', closeDetailedReactions); - } - - hasListener.current = false; - } - }, [messageDeleted, closeDetailedReactions, messageWrapperRef]); - - const onReactionListClick: ReactEventHandler = (event) => { - event?.stopPropagation?.(); - setShowDetailedReactions((prev) => !prev); - }; - - return { - isReactionEnabled, - onReactionListClick, - showDetailedReactions, - }; -}; diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index 9d659e4f8..26e6a8c23 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -314,6 +314,10 @@ export const areMessagePropsEqual = < return false; } + if (nextProps.closeReactionSelectorOnClick !== prevProps.closeReactionSelectorOnClick) { + return false; + } + const messagesAreEqual = areMessagesEqual(prevMessage, nextMessage); if (!messagesAreEqual) return false; diff --git a/src/components/Reactions/ReactionSelector.tsx b/src/components/Reactions/ReactionSelector.tsx index d8fa2b53d..9f2d9475b 100644 --- a/src/components/Reactions/ReactionSelector.tsx +++ b/src/components/Reactions/ReactionSelector.tsx @@ -1,9 +1,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import clsx from 'clsx'; -import { isMutableRef } from './utils/utils'; - import { AvatarProps, Avatar as DefaultAvatar } from '../Avatar'; +import { useDialog } from '../Dialog'; import { useComponentContext } from '../../context/ComponentContext'; import { useMessageContext } from '../../context/MessageContext'; @@ -12,6 +11,7 @@ import type { ReactionGroupResponse, ReactionResponse } from 'stream-chat'; import type { DefaultStreamChatGenerics } from '../../types/types'; import type { ReactionOptions } from './reactionOptions'; +import { isMutableRef } from './utils/utils'; export type ReactionSelectorProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics @@ -39,181 +39,191 @@ export type ReactionSelectorProps< reverse?: boolean; }; -const UnMemoizedReactionSelector = React.forwardRef( - ( - props: ReactionSelectorProps, - ref: React.ForwardedRef, - ) => { - const { - Avatar: propAvatar, - detailedView = true, - handleReaction: propHandleReaction, - latest_reactions: propLatestReactions, - own_reactions: propOwnReactions, - reaction_groups: propReactionGroups, - reactionOptions: propReactionOptions, - reverse = false, - } = props; - - const { - Avatar: contextAvatar, - reactionOptions: contextReactionOptions, - } = useComponentContext('ReactionSelector'); - const { - handleReaction: contextHandleReaction, - message, - } = useMessageContext('ReactionSelector'); - - const reactionOptions = propReactionOptions ?? contextReactionOptions; - - const Avatar = propAvatar || contextAvatar || DefaultAvatar; - const handleReaction = propHandleReaction || contextHandleReaction; - const latestReactions = propLatestReactions || message?.latest_reactions || []; - const ownReactions = propOwnReactions || message?.own_reactions || []; - const reactionGroups = propReactionGroups || message?.reaction_groups || {}; - - const [tooltipReactionType, setTooltipReactionType] = useState(null); - const [tooltipPositions, setTooltipPositions] = useState<{ - arrow: number; - tooltip: number; - } | null>(null); - - const targetRef = useRef(null); - const tooltipRef = useRef(null); - - const showTooltip = useCallback( - (event: React.MouseEvent, reactionType: string) => { - targetRef.current = event.currentTarget; - setTooltipReactionType(reactionType); - }, - [], - ); - - const hideTooltip = useCallback(() => { - setTooltipReactionType(null); - setTooltipPositions(null); - }, []); - - useEffect(() => { - if (tooltipReactionType) { - const tooltip = tooltipRef.current?.getBoundingClientRect(); - const target = targetRef.current?.getBoundingClientRect(); - - const container = isMutableRef(ref) ? ref.current?.getBoundingClientRect() : null; - - if (!tooltip || !target || !container) return; - - const tooltipPosition = - tooltip.width === container.width || tooltip.x < container.x - ? 0 - : target.left + target.width / 2 - container.left - tooltip.width / 2; - - const arrowPosition = target.x - tooltip.x + target.width / 2 - tooltipPosition; - - setTooltipPositions({ - arrow: arrowPosition, - tooltip: tooltipPosition, - }); - } - }, [tooltipReactionType, ref]); - - const getUsersPerReactionType = (type: string | null) => - latestReactions - .map((reaction) => { - if (reaction.type === type) { - return reaction.user?.name || reaction.user?.id; - } - return null; - }) - .filter(Boolean); - - const iHaveReactedWithReaction = (reactionType: string) => - ownReactions.find((reaction) => reaction.type === reactionType); - - const getLatestUserForReactionType = (type: string | null) => - latestReactions.find((reaction) => reaction.type === type && !!reaction.user)?.user || - undefined; - - return ( -
( + props: ReactionSelectorProps, +) => { + const { + Avatar: propAvatar, + detailedView = true, + handleReaction: propHandleReaction, + latest_reactions: propLatestReactions, + own_reactions: propOwnReactions, + reaction_groups: propReactionGroups, + reactionOptions: propReactionOptions, + reverse = false, + } = props; + + const { + Avatar: contextAvatar, + reactionOptions: contextReactionOptions, + } = useComponentContext('ReactionSelector'); + const { + closeReactionSelectorOnClick, + handleReaction: contextHandleReaction, + message, + } = useMessageContext('ReactionSelector'); + const dialogId = `reaction-selector--${message.id}`; + const dialog = useDialog({ id: dialogId }); + const reactionOptions = propReactionOptions ?? contextReactionOptions; + + const Avatar = propAvatar || contextAvatar || DefaultAvatar; + const handleReaction = propHandleReaction || contextHandleReaction; + const latestReactions = propLatestReactions || message?.latest_reactions || []; + const ownReactions = propOwnReactions || message?.own_reactions || []; + const reactionGroups = propReactionGroups || message?.reaction_groups || {}; + + const [tooltipReactionType, setTooltipReactionType] = useState(null); + const [tooltipPositions, setTooltipPositions] = useState<{ + arrow: number; + tooltip: number; + } | null>(null); + + const rootRef = useRef(null); + const targetRef = useRef(null); + const tooltipRef = useRef(null); + + const showTooltip = useCallback( + (event: React.MouseEvent, reactionType: string) => { + targetRef.current = event.currentTarget; + setTooltipReactionType(reactionType); + }, + [], + ); + + const hideTooltip = useCallback(() => { + setTooltipReactionType(null); + setTooltipPositions(null); + }, []); + + useEffect(() => { + if (!tooltipReactionType || !rootRef.current) return; + const tooltip = tooltipRef.current?.getBoundingClientRect(); + const target = targetRef.current?.getBoundingClientRect(); + + const container = isMutableRef(rootRef) ? rootRef.current?.getBoundingClientRect() : null; + + if (!tooltip || !target || !container) return; + + const tooltipPosition = + tooltip.width === container.width || tooltip.x < container.x + ? 0 + : target.left + target.width / 2 - container.left - tooltip.width / 2; + + const arrowPosition = target.x - tooltip.x + target.width / 2 - tooltipPosition; + + setTooltipPositions({ + arrow: arrowPosition, + tooltip: tooltipPosition, + }); + }, [tooltipReactionType, rootRef]); + + const getUsersPerReactionType = (type: string | null) => + latestReactions + .map((reaction) => { + if (reaction.type === type) { + return reaction.user?.name || reaction.user?.id; + } + return null; + }) + .filter(Boolean); + + const iHaveReactedWithReaction = (reactionType: string) => + ownReactions.find((reaction) => reaction.type === reactionType); + + const getLatestUserForReactionType = (type: string | null) => + latestReactions.find((reaction) => reaction.type === type && !!reaction.user)?.user || + undefined; + + return ( +
- {!!tooltipReactionType && detailedView && ( -
-
- {getUsersPerReactionType(tooltipReactionType)?.map((user, i, users) => ( - - {`${user}${i < users.length - 1 ? ', ' : ''}`} - - ))} -
- )} -
    - {reactionOptions.map(({ Component, name: reactionName, type: reactionType }) => { - const latestUser = getLatestUserForReactionType(reactionType); - const count = reactionGroups[reactionType]?.count ?? 0; - return ( -
  • - -
  • - ); - })} -
-
- ); - }, -); + )} + + + ); + })} + +
+ ); +}; /** * Component that allows a user to select a reaction. diff --git a/src/components/Reactions/ReactionSelectorWithButton.tsx b/src/components/Reactions/ReactionSelectorWithButton.tsx new file mode 100644 index 000000000..5a083e7d2 --- /dev/null +++ b/src/components/Reactions/ReactionSelectorWithButton.tsx @@ -0,0 +1,54 @@ +import React, { ElementRef, useRef } from 'react'; +import { ReactionSelector as DefaultReactionSelector } from './ReactionSelector'; +import { DialogAnchor, useDialog, useDialogIsOpen } from '../Dialog'; +import { useComponentContext, useMessageContext, useTranslationContext } from '../../context'; +import type { DefaultStreamChatGenerics } from '../../types'; +import type { IconProps } from '../../types/types'; + +type ReactionSelectorWithButtonProps = { + /* Custom component rendering the icon used in a button invoking reactions selector for a given message. */ + ReactionIcon: React.ComponentType; + /* Theme string to be added to CSS class names. */ + theme: string; +}; + +/** + * Internal convenience component - not to be exported. It just groups the button and the dialog anchor and thus prevents + * cluttering the parent component. + */ +export const ReactionSelectorWithButton = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ + ReactionIcon, + theme, +}: ReactionSelectorWithButtonProps) => { + const { t } = useTranslationContext('ReactionSelectorWithButton'); + const { isMyMessage, message } = useMessageContext('MessageOptions'); + const { ReactionSelector = DefaultReactionSelector } = useComponentContext('MessageOptions'); + const buttonRef = useRef>(null); + const dialogId = `reaction-selector--${message.id}`; + const dialog = useDialog({ id: dialogId }); + const dialogIsOpen = useDialogIsOpen(dialogId); + return ( + <> + + + + + + ); +}; diff --git a/src/components/Reactions/__tests__/ReactionSelector.test.js b/src/components/Reactions/__tests__/ReactionSelector.test.js index 500d9d9e4..0c159777e 100644 --- a/src/components/Reactions/__tests__/ReactionSelector.test.js +++ b/src/components/Reactions/__tests__/ReactionSelector.test.js @@ -13,8 +13,9 @@ import { Avatar as AvatarMock } from '../../Avatar'; import { ComponentProvider } from '../../../context/ComponentContext'; import { MessageProvider } from '../../../context/MessageContext'; +import { DialogsManagerProvider } from '../../../context'; -import { generateReaction, generateUser } from '../../../mock-builders'; +import { generateMessage, generateReaction, generateUser } from '../../../mock-builders'; jest.mock('../../Avatar', () => ({ Avatar: jest.fn(() =>
), @@ -35,11 +36,13 @@ const handleReactionMock = jest.fn(); const renderComponent = (props) => render( - - - - - , + + + + + + + , ); describe('ReactionSelector', () => { diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx index 77ac4ed9b..7a602ad5b 100644 --- a/src/context/MessageContext.tsx +++ b/src/context/MessageContext.tsx @@ -70,10 +70,6 @@ export type MessageContextValue< handleRetry: ChannelActionContextValue['retrySendMessage']; /** Function that returns whether the Message belongs to the current user */ isMyMessage: () => boolean; - /** @deprecated will be removed in the next major release. - * Whether sending reactions is enabled for the active channel. - */ - isReactionEnabled: boolean; /** The message object */ message: StreamMessage; /** Indicates whether a message has not been read yet or has been marked unread */ @@ -82,22 +78,18 @@ export type MessageContextValue< onMentionsClickMessage: ReactEventHandler; /** Handler function for a hover event on an @mention in Message */ onMentionsHoverMessage: ReactEventHandler; - /** Handler function for a click event on the reaction list */ - onReactionListClick: ReactEventHandler; /** Handler function for a click event on the user that posted the Message */ onUserClick: ReactEventHandler; /** Handler function for a hover event on the user that posted the Message */ onUserHover: ReactEventHandler; - /** Ref to be placed on the reaction selector component */ - reactionSelectorRef: React.MutableRefObject; /** Function to toggle the edit state on a Message */ setEditingState: ReactEventHandler; - /** Whether or not to show reaction list details */ - showDetailedReactions: boolean; /** Additional props for underlying MessageInput component, [available props](https://getstream.io/chat/docs/sdk/react/message-input-components/message_input/#props) */ additionalMessageInputProps?: MessageInputProps; /** Call this function to keep message list scrolled to the bottom when the scroll height increases, e.g. an element appears below the last message (only used in the `VirtualizedMessageList`) */ autoscrollToBottom?: () => void; + /** Message component configuration prop. If true, picking a reaction from the `ReactionSelector` component will close the selector */ + closeReactionSelectorOnClick?: boolean; /** Object containing custom message actions and function handlers */ customMessageActions?: CustomMessageActions; /** If true, the message is the last one in a group sent by a specific user (only used in the `VirtualizedMessageList`) */ From 8d1c98a865d8be2d6102a16a7c806b7fc1b6dc0c Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 9 Sep 2024 16:03:47 +0200 Subject: [PATCH 15/29] fix: close MessageActionsBox on click inside --- src/components/MessageActions/MessageActions.tsx | 11 ++++++++--- .../MessageActions/__tests__/MessageActions.test.js | 8 ++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx index 7ee4145c0..aa27f68d1 100644 --- a/src/components/MessageActions/MessageActions.tsx +++ b/src/components/MessageActions/MessageActions.tsx @@ -103,7 +103,11 @@ export const MessageActions = < if (!renderMessageActions) return null; return ( - + @@ -141,10 +144,11 @@ export const MessageActions = < export type MessageActionsWrapperProps = { customWrapperClass?: string; inline?: boolean; + toggleOpen?: () => void; }; const MessageActionsWrapper = (props: PropsWithChildren) => { - const { children, customWrapperClass, inline } = props; + const { children, customWrapperClass, inline, toggleOpen } = props; const defaultWrapperClass = clsx( 'str-chat__message-simple__actions__action', @@ -155,6 +159,7 @@ const MessageActionsWrapper = (props: PropsWithChildren{children}; diff --git a/src/components/MessageActions/__tests__/MessageActions.test.js b/src/components/MessageActions/__tests__/MessageActions.test.js index 13122e5e7..04677f656 100644 --- a/src/components/MessageActions/__tests__/MessageActions.test.js +++ b/src/components/MessageActions/__tests__/MessageActions.test.js @@ -46,7 +46,7 @@ const messageContextValue = { const chatClient = getTestClient(); -function renderMessageActions(customProps, renderer = render) { +function renderMessageActions(customProps = {}, renderer = render) { return renderer( @@ -83,6 +83,7 @@ describe(' component', () => {
-
-
+ /> +
From f76cc75a67437119eab608b713f5c39052ad925c Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 11 Sep 2024 10:00:10 +0200 Subject: [PATCH 19/29] refactor: rename DialogsManager to DialogManager --- .../{DialogsManager.ts => DialogManager.ts} | 30 +++++++++---------- src/components/Dialog/DialogPortal.tsx | 20 ++++++------- .../Dialog/__tests__/DialogsManager.test.js | 30 +++++++++---------- src/components/Dialog/hooks/useDialog.ts | 18 +++++------ src/components/Dialog/index.ts | 4 +-- .../Message/__tests__/MessageOptions.test.js | 6 ++-- .../Message/__tests__/QuotedMessage.test.js | 6 ++-- .../__tests__/MessageActions.test.js | 12 ++++---- .../__tests__/MessageActionsBox.test.js | 14 ++++----- src/components/MessageList/MessageList.tsx | 6 ++-- .../MessageList/VirtualizedMessageList.tsx | 6 ++-- .../VirtualizedMessageListComponents.test.js | 24 +++++++-------- .../VirtualizedMessageList.test.js.snap | 2 +- ...tualizedMessageListComponents.test.js.snap | 18 +++++------ .../__tests__/ReactionSelector.test.js | 6 ++-- src/context/DialogManagerContext.tsx | 27 +++++++++++++++++ src/context/DialogsManagerContext.tsx | 27 ----------------- src/context/index.ts | 2 +- 18 files changed, 128 insertions(+), 130 deletions(-) rename src/components/Dialog/{DialogsManager.ts => DialogManager.ts} (83%) create mode 100644 src/context/DialogManagerContext.tsx delete mode 100644 src/context/DialogsManagerContext.tsx diff --git a/src/components/Dialog/DialogsManager.ts b/src/components/Dialog/DialogManager.ts similarity index 83% rename from src/components/Dialog/DialogsManager.ts rename to src/components/Dialog/DialogManager.ts index 275f9f5ed..ace76c879 100644 --- a/src/components/Dialog/DialogsManager.ts +++ b/src/components/Dialog/DialogManager.ts @@ -22,14 +22,14 @@ type DialogInitOptions = { type Dialogs = Record; -type DialogsManagerState = { +type DialogManagerState = { dialogs: Dialogs; openDialogCount: number; }; -export class DialogsManager { +export class DialogManager { id: string; - state = new StateStore({ + state = new StateStore({ dialogs: {}, openDialogCount: 0, }); @@ -84,7 +84,6 @@ export class DialogsManager { close(id: DialogId) { const dialog = this.state.getLatestValue().dialogs[id]; if (!dialog?.isOpen) return; - dialog.isOpen = false; this.state.next((current) => ({ ...current, dialogs: { ...current.dialogs, [dialog.id]: { ...dialog, isOpen: false } }, @@ -117,17 +116,16 @@ export class DialogsManager { const dialog = state.dialogs[id]; if (!dialog) return; - this.state.next((current) => ({ - ...current, - dialogs: Object.entries(current.dialogs).reduce((acc, [dialogId, dialog]) => { - if (id !== dialogId) { - acc[id] = dialog; - } - return acc; - }, {}), - openDialogCount: - current.openDialogCount && - (dialog.isOpen ? current.openDialogCount - 1 : current.openDialogCount), - })); + this.state.next((current) => { + const newDialogs = { ...current.dialogs }; + delete newDialogs[id]; + return { + ...current, + dialogs: newDialogs, + openDialogCount: + current.openDialogCount && + (dialog.isOpen ? current.openDialogCount - 1 : current.openDialogCount), + }; + }); } } diff --git a/src/components/Dialog/DialogPortal.tsx b/src/components/Dialog/DialogPortal.tsx index a01ced9d6..eb3e912c1 100644 --- a/src/components/Dialog/DialogPortal.tsx +++ b/src/components/Dialog/DialogPortal.tsx @@ -1,31 +1,31 @@ import React, { PropsWithChildren, useEffect, useLayoutEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { useDialogIsOpen } from './hooks'; -import { useDialogsManager } from '../../context'; +import { useDialogManager } from '../../context'; export const DialogPortalDestination = () => { - const { dialogsManager } = useDialogsManager(); + const { dialogManager } = useDialogManager(); const [shouldRender, setShouldRender] = useState( - !!dialogsManager.state.getLatestValue().openDialogCount, + !!dialogManager.state.getLatestValue().openDialogCount, ); useEffect( () => - dialogsManager.state.subscribeWithSelector( + dialogManager.state.subscribeWithSelector( ({ openDialogCount }) => [openDialogCount], ([openDialogCount]) => { setShouldRender(openDialogCount > 0); }, ), - [dialogsManager], + [dialogManager], ); return (
dialogsManager.closeAll()} + onClick={() => dialogManager.closeAll()} style={ { '--str-chat__dialog-overlay-height': shouldRender ? '100%' : '0', @@ -43,16 +43,16 @@ export const DialogPortalEntry = ({ children, dialogId, }: PropsWithChildren) => { - const { dialogsManager } = useDialogsManager(); + const { dialogManager } = useDialogManager(); const dialogIsOpen = useDialogIsOpen(dialogId); const [portalDestination, setPortalDestination] = useState(null); useLayoutEffect(() => { const destination = document.querySelector( - `div[data-str-chat__portal-id="${dialogsManager.id}"]`, + `div[data-str-chat__portal-id="${dialogManager.id}"]`, ); if (!destination) return; setPortalDestination(destination); - }, [dialogsManager, dialogIsOpen]); + }, [dialogManager, dialogIsOpen]); if (!portalDestination) return null; diff --git a/src/components/Dialog/__tests__/DialogsManager.test.js b/src/components/Dialog/__tests__/DialogsManager.test.js index cc2c999f8..537e8c0f6 100644 --- a/src/components/Dialog/__tests__/DialogsManager.test.js +++ b/src/components/Dialog/__tests__/DialogsManager.test.js @@ -1,22 +1,22 @@ -import { DialogsManager } from '../DialogsManager'; +import { DialogManager } from '../DialogManager'; const dialogId = 'dialogId'; describe('DialogManager', () => { it('initiates with provided options', () => { const id = 'XX'; - const dm = new DialogsManager({ id }); + const dm = new DialogManager({ id }); expect(dm.id).toBe(id); }); it('initiates with default options', () => { const mockedId = '12345'; const spy = jest.spyOn(Date.prototype, 'getTime').mockReturnValueOnce(mockedId); - const dm = new DialogsManager(); + const dm = new DialogManager(); expect(dm.id).toBe(mockedId); spy.mockRestore(); }); it('creates a new closed dialog', () => { - const dm = new DialogsManager(); + const dm = new DialogManager(); expect(Object.keys(dm.state.getLatestValue().dialogs)).toHaveLength(0); expect(dm.getOrCreate({ id: dialogId })).toMatchObject({ close: expect.any(Function), @@ -32,7 +32,7 @@ describe('DialogManager', () => { }); it('retrieves an existing dialog', () => { - const dm = new DialogsManager(); + const dm = new DialogManager(); dm.state.next((current) => ({ ...current, dialogs: { ...current.dialogs, [dialogId]: { id: dialogId, isOpen: true } }, @@ -45,7 +45,7 @@ describe('DialogManager', () => { }); it('creates a dialog if it does not exist on open', () => { - const dm = new DialogsManager(); + const dm = new DialogManager(); dm.open({ id: dialogId }); expect(dm.state.getLatestValue().dialogs[dialogId]).toMatchObject({ close: expect.any(Function), @@ -60,7 +60,7 @@ describe('DialogManager', () => { }); it('opens existing dialog', () => { - const dm = new DialogsManager(); + const dm = new DialogManager(); dm.getOrCreate({ id: dialogId }); dm.open({ id: dialogId }); expect(dm.state.getLatestValue().dialogs[dialogId].isOpen).toBeTruthy(); @@ -68,7 +68,7 @@ describe('DialogManager', () => { }); it('does not open already open dialog', () => { - const dm = new DialogsManager(); + const dm = new DialogManager(); dm.getOrCreate({ id: dialogId }); dm.open({ id: dialogId }); dm.open({ id: dialogId }); @@ -76,7 +76,7 @@ describe('DialogManager', () => { }); it('closes all other dialogs before opening the target', () => { - const dm = new DialogsManager(); + const dm = new DialogManager(); dm.open({ id: 'xxx' }); dm.open({ id: 'yyy' }); expect(dm.state.getLatestValue().openDialogCount).toBe(2); @@ -89,7 +89,7 @@ describe('DialogManager', () => { }); it('closes opened dialog', () => { - const dm = new DialogsManager(); + const dm = new DialogManager(); dm.open({ id: dialogId }); dm.close(dialogId); expect(dm.state.getLatestValue().dialogs[dialogId].isOpen).toBeFalsy(); @@ -97,7 +97,7 @@ describe('DialogManager', () => { }); it('does not close already closed dialog', () => { - const dm = new DialogsManager(); + const dm = new DialogManager(); dm.open({ id: 'xxx' }); dm.open({ id: dialogId }); dm.close(dialogId); @@ -106,7 +106,7 @@ describe('DialogManager', () => { }); it('toggles the open state of a dialog', () => { - const dm = new DialogsManager(); + const dm = new DialogManager(); dm.open({ id: 'xxx' }); dm.open({ id: 'yyy' }); dm.toggleOpen({ id: dialogId }); @@ -116,7 +116,7 @@ describe('DialogManager', () => { }); it('keeps single opened dialog when the toggling open dialog state', () => { - const dm = new DialogsManager(); + const dm = new DialogManager(); dm.open({ id: 'xxx' }); dm.open({ id: 'yyy' }); @@ -128,7 +128,7 @@ describe('DialogManager', () => { }); it('removes a dialog', () => { - const dm = new DialogsManager(); + const dm = new DialogManager(); dm.getOrCreate({ id: dialogId }); dm.open({ id: dialogId }); dm.remove(dialogId); @@ -137,7 +137,7 @@ describe('DialogManager', () => { }); it('handles attempt to remove non-existent dialog', () => { - const dm = new DialogsManager(); + const dm = new DialogManager(); dm.getOrCreate({ id: dialogId }); dm.open({ id: dialogId }); dm.remove('xxx'); diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts index 77bab9acb..95d0ac4db 100644 --- a/src/components/Dialog/hooks/useDialog.ts +++ b/src/components/Dialog/hooks/useDialog.ts @@ -1,34 +1,34 @@ import { useEffect, useState } from 'react'; -import { useDialogsManager } from '../../../context/DialogsManagerContext'; -import type { GetOrCreateParams } from '../DialogsManager'; +import { useDialogManager } from '../../../context/DialogManagerContext'; +import type { GetOrCreateParams } from '../DialogManager'; export const useDialog = ({ id }: GetOrCreateParams) => { - const { dialogsManager } = useDialogsManager(); + const { dialogManager } = useDialogManager(); useEffect( () => () => { - dialogsManager.remove(id); + dialogManager.remove(id); }, - [dialogsManager, id], + [dialogManager, id], ); - return dialogsManager.getOrCreate({ id }); + return dialogManager.getOrCreate({ id }); }; export const useDialogIsOpen = (id: string, source?: string) => { - const { dialogsManager } = useDialogsManager(); + const { dialogManager } = useDialogManager(); const [open, setOpen] = useState(false); useEffect( () => - dialogsManager.state.subscribeWithSelector( + dialogManager.state.subscribeWithSelector( ({ dialogs }) => [!!dialogs[id]?.isOpen], ([isOpen]) => { setOpen(isOpen); }, // id, ), - [dialogsManager, id, source], + [dialogManager, id, source], ); return open; diff --git a/src/components/Dialog/index.ts b/src/components/Dialog/index.ts index 3bfd1c2dc..a2462dbcd 100644 --- a/src/components/Dialog/index.ts +++ b/src/components/Dialog/index.ts @@ -1,4 +1,4 @@ export * from './DialogAnchor'; -export * from './DialogsManager'; -export * from '../../context/DialogsManagerContext'; +export * from './DialogManager'; +export * from './DialogPortal'; export * from './hooks'; diff --git a/src/components/Message/__tests__/MessageOptions.test.js b/src/components/Message/__tests__/MessageOptions.test.js index f3b569c56..718cc9522 100644 --- a/src/components/Message/__tests__/MessageOptions.test.js +++ b/src/components/Message/__tests__/MessageOptions.test.js @@ -21,7 +21,7 @@ import { generateUser, getTestClientWithUser, } from '../../../mock-builders'; -import { DialogsManagerProvider } from '../../../context'; +import { DialogManagerProvider } from '../../../context'; import { defaultReactionOptions } from '../../Reactions'; const MESSAGE_ACTIONS_TEST_ID = 'message-actions'; @@ -55,7 +55,7 @@ async function renderMessageOptions({ return render( - + - + , ); } diff --git a/src/components/Message/__tests__/QuotedMessage.test.js b/src/components/Message/__tests__/QuotedMessage.test.js index b076be757..64f8beda8 100644 --- a/src/components/Message/__tests__/QuotedMessage.test.js +++ b/src/components/Message/__tests__/QuotedMessage.test.js @@ -9,7 +9,7 @@ import { ChannelStateProvider, ChatProvider, ComponentProvider, - DialogsManagerProvider, + DialogManagerProvider, TranslationProvider, } from '../../../context'; import { @@ -66,11 +66,11 @@ async function renderQuotedMessage(customProps) { Message: () => , }} > - + - + diff --git a/src/components/MessageActions/__tests__/MessageActions.test.js b/src/components/MessageActions/__tests__/MessageActions.test.js index 6ebe04950..1e03d80b3 100644 --- a/src/components/MessageActions/__tests__/MessageActions.test.js +++ b/src/components/MessageActions/__tests__/MessageActions.test.js @@ -10,7 +10,7 @@ import { ChannelStateProvider, ChatProvider, ComponentProvider, - DialogsManagerProvider, + DialogManagerProvider, MessageProvider, TranslationProvider, } from '../../../context'; @@ -49,7 +49,7 @@ const chatClient = getTestClient(); function renderMessageActions(customProps = {}, renderer = render) { return renderer( - + @@ -59,7 +59,7 @@ function renderMessageActions(customProps = {}, renderer = render) { - + , ); } @@ -108,7 +108,7 @@ describe(' component', () => {
,
component', () => {
,
component', () => { ,
- + - + @@ -227,14 +227,14 @@ describe('MessageActionsBox', () => { await render( - + - + , ); @@ -398,10 +398,10 @@ describe('MessageActionsBox', () => { await render( - + - + , ); diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 0ea696511..9a244c901 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -21,7 +21,7 @@ import { ChannelStateContextValue, useChannelStateContext, } from '../../context/ChannelStateContext'; -import { DialogsManagerProvider } from '../../context'; +import { DialogManagerProvider } from '../../context'; import { useChatContext } from '../../context/ChatContext'; import { useComponentContext } from '../../context/ComponentContext'; import { MessageListContextProvider } from '../../context/MessageListContext'; @@ -224,7 +224,7 @@ const MessageListWithContext = < return ( - + {!threadList && showUnreadMessagesNotification && ( )} @@ -263,7 +263,7 @@ const MessageListWithContext = < )}
- + - + {!threadList && showUnreadMessagesNotification && ( )} @@ -499,7 +499,7 @@ const VirtualizedMessageListWithContext = < {...(defaultItemHeight ? { defaultItemHeight } : {})} />
- + {TypingIndicator && } ( - + {children} - + @@ -92,7 +92,7 @@ describe('VirtualizedMessageComponents', () => {
@@ -121,7 +121,7 @@ describe('VirtualizedMessageComponents', () => {
@@ -136,7 +136,7 @@ describe('VirtualizedMessageComponents', () => {
@@ -165,7 +165,7 @@ describe('VirtualizedMessageComponents', () => {
@@ -183,7 +183,7 @@ describe('VirtualizedMessageComponents', () => {
@@ -209,7 +209,7 @@ describe('VirtualizedMessageComponents', () => {
@@ -235,7 +235,7 @@ describe('VirtualizedMessageComponents', () => {
@@ -262,7 +262,7 @@ describe('VirtualizedMessageComponents', () => {
@@ -280,7 +280,7 @@ describe('VirtualizedMessageComponents', () => {
diff --git a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap index 6b30d6519..7c708d272 100644 --- a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap +++ b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap @@ -69,7 +69,7 @@ exports[`VirtualizedMessageList should render the list without any message 1`] =
@@ -25,7 +25,7 @@ exports[`VirtualizedMessageComponents EmptyPlaceholder should render custom Empt
@@ -59,7 +59,7 @@ exports[`VirtualizedMessageComponents EmptyPlaceholder should render for main me
@@ -117,7 +117,7 @@ exports[`VirtualizedMessageComponents Header should not render custom head in He
@@ -172,7 +172,7 @@ exports[`VirtualizedMessageComponents Header should render LoadingIndicator in H
@@ -187,7 +187,7 @@ exports[`VirtualizedMessageComponents Item should render wrapper with custom cla />
@@ -202,7 +202,7 @@ exports[`VirtualizedMessageComponents Item should render wrapper with custom cla />
@@ -217,7 +217,7 @@ exports[`VirtualizedMessageComponents Item should render wrapper without custom />
@@ -232,7 +232,7 @@ exports[`VirtualizedMessageComponents Item should render wrapper without custom />
diff --git a/src/components/Reactions/__tests__/ReactionSelector.test.js b/src/components/Reactions/__tests__/ReactionSelector.test.js index 0c159777e..3b668ea76 100644 --- a/src/components/Reactions/__tests__/ReactionSelector.test.js +++ b/src/components/Reactions/__tests__/ReactionSelector.test.js @@ -13,7 +13,7 @@ import { Avatar as AvatarMock } from '../../Avatar'; import { ComponentProvider } from '../../../context/ComponentContext'; import { MessageProvider } from '../../../context/MessageContext'; -import { DialogsManagerProvider } from '../../../context'; +import { DialogManagerProvider } from '../../../context'; import { generateMessage, generateReaction, generateUser } from '../../../mock-builders'; @@ -36,13 +36,13 @@ const handleReactionMock = jest.fn(); const renderComponent = (props) => render( - + - , + , ); describe('ReactionSelector', () => { diff --git a/src/context/DialogManagerContext.tsx b/src/context/DialogManagerContext.tsx new file mode 100644 index 000000000..b1f14126d --- /dev/null +++ b/src/context/DialogManagerContext.tsx @@ -0,0 +1,27 @@ +import React, { PropsWithChildren, useContext, useState } from 'react'; +import { DialogManager } from '../components/Dialog/DialogManager'; +import { DialogPortalDestination } from '../components/Dialog/DialogPortal'; + +type DialogManagerProviderContextValue = { + dialogManager: DialogManager; +}; + +const DialogManagerProviderContext = React.createContext< + DialogManagerProviderContextValue | undefined +>(undefined); + +export const DialogManagerProvider = ({ children, id }: PropsWithChildren<{ id?: string }>) => { + const [dialogManager] = useState(() => new DialogManager({ id })); + + return ( + + {children} + + + ); +}; + +export const useDialogManager = () => { + const value = useContext(DialogManagerProviderContext); + return value as DialogManagerProviderContextValue; +}; diff --git a/src/context/DialogsManagerContext.tsx b/src/context/DialogsManagerContext.tsx deleted file mode 100644 index 1e564e7ee..000000000 --- a/src/context/DialogsManagerContext.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React, { PropsWithChildren, useContext, useState } from 'react'; -import { DialogsManager } from '../components/Dialog/DialogsManager'; -import { DialogPortalDestination } from '../components/Dialog/DialogPortal'; - -type DialogsManagerProviderContextValue = { - dialogsManager: DialogsManager; -}; - -const DialogsManagerProviderContext = React.createContext< - DialogsManagerProviderContextValue | undefined ->(undefined); - -export const DialogsManagerProvider = ({ children, id }: PropsWithChildren<{ id?: string }>) => { - const [dialogsManager] = useState(() => new DialogsManager({ id })); - - return ( - - {children} - - - ); -}; - -export const useDialogsManager = () => { - const value = useContext(DialogsManagerProviderContext); - return value as DialogsManagerProviderContextValue; -}; diff --git a/src/context/index.ts b/src/context/index.ts index 1dca38291..15e3f422b 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -3,7 +3,7 @@ export * from './ChannelListContext'; export * from './ChannelStateContext'; export * from './ChatContext'; export * from './ComponentContext'; -export * from './DialogsManagerContext'; +export * from './DialogManagerContext'; export * from './MessageContext'; export * from './MessageBounceContext'; export * from './MessageInputContext'; From cb52d15e98db4a0534429fe1c40d2e61eedf77a6 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 11 Sep 2024 10:10:21 +0200 Subject: [PATCH 20/29] refactor: rename DialogsManager.open param single to closeRest --- src/components/Dialog/DialogManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Dialog/DialogManager.ts b/src/components/Dialog/DialogManager.ts index ace76c879..1882eb32b 100644 --- a/src/components/Dialog/DialogManager.ts +++ b/src/components/Dialog/DialogManager.ts @@ -68,10 +68,10 @@ export class DialogManager { return dialog; } - open(params: GetOrCreateParams, single?: boolean) { + open(params: GetOrCreateParams, closeRest?: boolean) { const dialog = this.getOrCreate(params); if (dialog.isOpen) return; - if (single) { + if (closeRest) { this.closeAll(); } this.state.next((current) => ({ From 45c5eb742e43e5ca2193008d33d124067262ca77 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 11 Sep 2024 10:12:43 +0200 Subject: [PATCH 21/29] refactor: rename DialogsManager.state.dialogs to DialogsManager.state.dialogsById --- src/components/Dialog/DialogManager.ts | 26 +++++++++---------- .../Dialog/__tests__/DialogsManager.test.js | 24 ++++++++--------- src/components/Dialog/hooks/useDialog.ts | 2 +- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/components/Dialog/DialogManager.ts b/src/components/Dialog/DialogManager.ts index 1882eb32b..479dd63c4 100644 --- a/src/components/Dialog/DialogManager.ts +++ b/src/components/Dialog/DialogManager.ts @@ -23,14 +23,14 @@ type DialogInitOptions = { type Dialogs = Record; type DialogManagerState = { - dialogs: Dialogs; + dialogsById: Dialogs; openDialogCount: number; }; export class DialogManager { id: string; state = new StateStore({ - dialogs: {}, + dialogsById: {}, openDialogCount: 0, }); @@ -39,7 +39,7 @@ export class DialogManager { } getOrCreate({ id }: GetOrCreateParams) { - let dialog = this.state.getLatestValue().dialogs[id]; + let dialog = this.state.getLatestValue().dialogsById[id]; if (!dialog) { dialog = { close: () => { @@ -62,7 +62,7 @@ export class DialogManager { }; this.state.next((current) => ({ ...current, - ...{ dialogs: { ...current.dialogs, [id]: dialog } }, + ...{ dialogsById: { ...current.dialogsById, [id]: dialog } }, })); } return dialog; @@ -76,27 +76,27 @@ export class DialogManager { } this.state.next((current) => ({ ...current, - dialogs: { ...current.dialogs, [dialog.id]: { ...dialog, isOpen: true } }, + dialogsById: { ...current.dialogsById, [dialog.id]: { ...dialog, isOpen: true } }, openDialogCount: ++current.openDialogCount, })); } close(id: DialogId) { - const dialog = this.state.getLatestValue().dialogs[id]; + const dialog = this.state.getLatestValue().dialogsById[id]; if (!dialog?.isOpen) return; this.state.next((current) => ({ ...current, - dialogs: { ...current.dialogs, [dialog.id]: { ...dialog, isOpen: false } }, + dialogsById: { ...current.dialogsById, [dialog.id]: { ...dialog, isOpen: false } }, openDialogCount: --current.openDialogCount, })); } closeAll() { - Object.values(this.state.getLatestValue().dialogs).forEach((dialog) => dialog.close()); + Object.values(this.state.getLatestValue().dialogsById).forEach((dialog) => dialog.close()); } toggleOpen(params: GetOrCreateParams) { - if (this.state.getLatestValue().dialogs[params.id]?.isOpen) { + if (this.state.getLatestValue().dialogsById[params.id]?.isOpen) { this.close(params.id); } else { this.open(params); @@ -104,7 +104,7 @@ export class DialogManager { } toggleOpenSingle(params: GetOrCreateParams) { - if (this.state.getLatestValue().dialogs[params.id]?.isOpen) { + if (this.state.getLatestValue().dialogsById[params.id]?.isOpen) { this.close(params.id); } else { this.open(params, true); @@ -113,15 +113,15 @@ export class DialogManager { remove(id: DialogId) { const state = this.state.getLatestValue(); - const dialog = state.dialogs[id]; + const dialog = state.dialogsById[id]; if (!dialog) return; this.state.next((current) => { - const newDialogs = { ...current.dialogs }; + const newDialogs = { ...current.dialogsById }; delete newDialogs[id]; return { ...current, - dialogs: newDialogs, + dialogsById: newDialogs, openDialogCount: current.openDialogCount && (dialog.isOpen ? current.openDialogCount - 1 : current.openDialogCount), diff --git a/src/components/Dialog/__tests__/DialogsManager.test.js b/src/components/Dialog/__tests__/DialogsManager.test.js index 537e8c0f6..23895e8c5 100644 --- a/src/components/Dialog/__tests__/DialogsManager.test.js +++ b/src/components/Dialog/__tests__/DialogsManager.test.js @@ -17,7 +17,7 @@ describe('DialogManager', () => { }); it('creates a new closed dialog', () => { const dm = new DialogManager(); - expect(Object.keys(dm.state.getLatestValue().dialogs)).toHaveLength(0); + expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(0); expect(dm.getOrCreate({ id: dialogId })).toMatchObject({ close: expect.any(Function), id: 'dialogId', @@ -27,7 +27,7 @@ describe('DialogManager', () => { toggle: expect.any(Function), toggleSingle: expect.any(Function), }); - expect(Object.keys(dm.state.getLatestValue().dialogs)).toHaveLength(1); + expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(1); expect(dm.state.getLatestValue().openDialogCount).toBe(0); }); @@ -35,19 +35,19 @@ describe('DialogManager', () => { const dm = new DialogManager(); dm.state.next((current) => ({ ...current, - dialogs: { ...current.dialogs, [dialogId]: { id: dialogId, isOpen: true } }, + dialogsById: { ...current.dialogsById, [dialogId]: { id: dialogId, isOpen: true } }, })); expect(dm.getOrCreate({ id: dialogId })).toMatchObject({ id: 'dialogId', isOpen: true, }); - expect(Object.keys(dm.state.getLatestValue().dialogs)).toHaveLength(1); + expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(1); }); it('creates a dialog if it does not exist on open', () => { const dm = new DialogManager(); dm.open({ id: dialogId }); - expect(dm.state.getLatestValue().dialogs[dialogId]).toMatchObject({ + expect(dm.state.getLatestValue().dialogsById[dialogId]).toMatchObject({ close: expect.any(Function), id: 'dialogId', isOpen: true, @@ -63,7 +63,7 @@ describe('DialogManager', () => { const dm = new DialogManager(); dm.getOrCreate({ id: dialogId }); dm.open({ id: dialogId }); - expect(dm.state.getLatestValue().dialogs[dialogId].isOpen).toBeTruthy(); + expect(dm.state.getLatestValue().dialogsById[dialogId].isOpen).toBeTruthy(); expect(dm.state.getLatestValue().openDialogCount).toBe(1); }); @@ -75,16 +75,16 @@ describe('DialogManager', () => { expect(dm.state.getLatestValue().openDialogCount).toBe(1); }); - it('closes all other dialogs before opening the target', () => { + it('closes all other dialogsById before opening the target', () => { const dm = new DialogManager(); dm.open({ id: 'xxx' }); dm.open({ id: 'yyy' }); expect(dm.state.getLatestValue().openDialogCount).toBe(2); dm.open({ id: dialogId }, true); - const dialogs = dm.state.getLatestValue().dialogs; + const dialogs = dm.state.getLatestValue().dialogsById; expect(dialogs.xxx.isOpen).toBeFalsy(); expect(dialogs.yyy.isOpen).toBeFalsy(); - expect(dm.state.getLatestValue().dialogs[dialogId].isOpen).toBeTruthy(); + expect(dm.state.getLatestValue().dialogsById[dialogId].isOpen).toBeTruthy(); expect(dm.state.getLatestValue().openDialogCount).toBe(1); }); @@ -92,7 +92,7 @@ describe('DialogManager', () => { const dm = new DialogManager(); dm.open({ id: dialogId }); dm.close(dialogId); - expect(dm.state.getLatestValue().dialogs[dialogId].isOpen).toBeFalsy(); + expect(dm.state.getLatestValue().dialogsById[dialogId].isOpen).toBeFalsy(); expect(dm.state.getLatestValue().openDialogCount).toBe(0); }); @@ -133,7 +133,7 @@ describe('DialogManager', () => { dm.open({ id: dialogId }); dm.remove(dialogId); expect(dm.state.getLatestValue().openDialogCount).toBe(0); - expect(Object.keys(dm.state.getLatestValue().dialogs)).toHaveLength(0); + expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(0); }); it('handles attempt to remove non-existent dialog', () => { @@ -142,6 +142,6 @@ describe('DialogManager', () => { dm.open({ id: dialogId }); dm.remove('xxx'); expect(dm.state.getLatestValue().openDialogCount).toBe(1); - expect(Object.keys(dm.state.getLatestValue().dialogs)).toHaveLength(1); + expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(1); }); }); diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts index 95d0ac4db..f3ab3ab39 100644 --- a/src/components/Dialog/hooks/useDialog.ts +++ b/src/components/Dialog/hooks/useDialog.ts @@ -22,7 +22,7 @@ export const useDialogIsOpen = (id: string, source?: string) => { useEffect( () => dialogManager.state.subscribeWithSelector( - ({ dialogs }) => [!!dialogs[id]?.isOpen], + ({ dialogsById }) => [!!dialogsById[id]?.isOpen], ([isOpen]) => { setOpen(isOpen); }, From 401af819ef9b7ab2a7c8b436361a638d12ceda0c Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 11 Sep 2024 12:13:26 +0200 Subject: [PATCH 22/29] refactor: move useStateStore to src/store/hooks --- src/components/ChatView/ChatView.tsx | 3 ++- src/components/Message/MessageOptions.tsx | 10 +++++----- .../Message/__tests__/MessageOptions.test.js | 14 ++++++++------ src/components/Thread/Thread.tsx | 3 ++- src/components/Threads/ThreadList/ThreadList.tsx | 2 +- .../Threads/ThreadList/ThreadListItemUI.tsx | 2 +- .../ThreadList/ThreadListLoadingIndicator.tsx | 2 +- .../ThreadList/ThreadListUnseenThreadsBanner.tsx | 2 +- .../Threads/hooks/useThreadManagerState.ts | 2 +- src/components/Threads/hooks/useThreadState.ts | 2 +- src/components/Threads/index.ts | 1 - src/index.ts | 1 + src/store/hooks/index.ts | 1 + .../Threads => store}/hooks/useStateStore.ts | 0 src/store/index.ts | 1 + 15 files changed, 26 insertions(+), 20 deletions(-) create mode 100644 src/store/hooks/index.ts rename src/{components/Threads => store}/hooks/useStateStore.ts (100%) create mode 100644 src/store/index.ts diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx index 5d6aea889..f14261dee 100644 --- a/src/components/ChatView/ChatView.tsx +++ b/src/components/ChatView/ChatView.tsx @@ -1,9 +1,10 @@ import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; -import { ThreadProvider, useStateStore } from '../Threads'; +import { ThreadProvider } from '../Threads'; import { Icon } from '../Threads/icons'; import { UnreadCountBadge } from '../Threads/UnreadCountBadge'; import { useChatContext } from '../../context'; +import { useStateStore } from '../../store'; import type { PropsWithChildren } from 'react'; import type { Thread, ThreadManagerState } from 'stream-chat'; diff --git a/src/components/Message/MessageOptions.tsx b/src/components/Message/MessageOptions.tsx index f8890ea21..3da40fcb8 100644 --- a/src/components/Message/MessageOptions.tsx +++ b/src/components/Message/MessageOptions.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import React from 'react'; import { @@ -7,14 +8,13 @@ import { } from './icons'; import { MESSAGE_ACTIONS } from './utils'; import { MessageActions } from '../MessageActions'; +import { useDialogIsOpen } from '../Dialog'; +import { ReactionSelectorWithButton } from '../Reactions/ReactionSelectorWithButton'; -import { useTranslationContext } from '../../context'; -import { MessageContextValue, useMessageContext } from '../../context/MessageContext'; +import { useMessageContext, useTranslationContext } from '../../context'; import type { DefaultStreamChatGenerics, IconProps } from '../../types/types'; -import { ReactionSelectorWithButton } from '../Reactions/ReactionSelectorWithButton'; -import { useDialogIsOpen } from '../Dialog'; -import clsx from 'clsx'; +import type { MessageContextValue } from '../../context/MessageContext'; export type MessageOptionsProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics diff --git a/src/components/Message/__tests__/MessageOptions.test.js b/src/components/Message/__tests__/MessageOptions.test.js index 718cc9522..1b2162fa0 100644 --- a/src/components/Message/__tests__/MessageOptions.test.js +++ b/src/components/Message/__tests__/MessageOptions.test.js @@ -9,11 +9,15 @@ import { MessageSimple } from '../MessageSimple'; import { ACTIONS_NOT_WORKING_IN_THREAD, MESSAGE_ACTIONS } from '../utils'; import { Attachment } from '../../Attachment'; +import { defaultReactionOptions } from '../../Reactions'; -import { ChannelActionProvider } from '../../../context/ChannelActionContext'; -import { ChannelStateProvider } from '../../../context/ChannelStateContext'; -import { ChatProvider } from '../../../context/ChatContext'; -import { ComponentProvider } from '../../../context/ComponentContext'; +import { + ChannelActionProvider, + ChannelStateProvider, + ChatProvider, + ComponentProvider, + DialogManagerProvider, +} from '../../../context'; import { generateChannel, @@ -21,8 +25,6 @@ import { generateUser, getTestClientWithUser, } from '../../../mock-builders'; -import { DialogManagerProvider } from '../../../context'; -import { defaultReactionOptions } from '../../Reactions'; const MESSAGE_ACTIONS_TEST_ID = 'message-actions'; diff --git a/src/components/Thread/Thread.tsx b/src/components/Thread/Thread.tsx index 6ede0894a..a02b1c737 100644 --- a/src/components/Thread/Thread.tsx +++ b/src/components/Thread/Thread.tsx @@ -18,7 +18,8 @@ import { useChatContext, useComponentContext, } from '../../context'; -import { useStateStore, useThreadContext } from '../../components/Threads'; +import { useThreadContext } from '../Threads'; +import { useStateStore } from '../../store'; import type { MessageProps, MessageUIComponentProps } from '../Message/types'; import type { MessageActionsArray } from '../Message/utils'; diff --git a/src/components/Threads/ThreadList/ThreadList.tsx b/src/components/Threads/ThreadList/ThreadList.tsx index fdec3a520..e397bd427 100644 --- a/src/components/Threads/ThreadList/ThreadList.tsx +++ b/src/components/Threads/ThreadList/ThreadList.tsx @@ -8,7 +8,7 @@ import { ThreadListEmptyPlaceholder as DefaultThreadListEmptyPlaceholder } from import { ThreadListUnseenThreadsBanner as DefaultThreadListUnseenThreadsBanner } from './ThreadListUnseenThreadsBanner'; import { ThreadListLoadingIndicator as DefaultThreadListLoadingIndicator } from './ThreadListLoadingIndicator'; import { useChatContext, useComponentContext } from '../../../context'; -import { useStateStore } from '../hooks/useStateStore'; +import { useStateStore } from '../../../store'; const selector = (nextValue: ThreadManagerState) => [nextValue.threads] as const; diff --git a/src/components/Threads/ThreadList/ThreadListItemUI.tsx b/src/components/Threads/ThreadList/ThreadListItemUI.tsx index f64ffdc86..f1cef2dd0 100644 --- a/src/components/Threads/ThreadList/ThreadListItemUI.tsx +++ b/src/components/Threads/ThreadList/ThreadListItemUI.tsx @@ -11,7 +11,7 @@ import { UnreadCountBadge } from '../UnreadCountBadge'; import { useChatContext } from '../../../context'; import { useThreadsViewContext } from '../../ChatView'; import { useThreadListItemContext } from './ThreadListItem'; -import { useStateStore } from '../hooks/useStateStore'; +import { useStateStore } from '../../../store'; export type ThreadListItemUIProps = ComponentPropsWithoutRef<'button'>; diff --git a/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx b/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx index da9da4ea4..e778b3035 100644 --- a/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx +++ b/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx @@ -4,7 +4,7 @@ import type { ThreadManagerState } from 'stream-chat'; import { LoadingIndicator as DefaultLoadingIndicator } from '../../Loading'; import { useChatContext, useComponentContext } from '../../../context'; -import { useStateStore } from '../hooks/useStateStore'; +import { useStateStore } from '../../../store'; const selector = (nextValue: ThreadManagerState) => [nextValue.pagination.isLoadingNext]; diff --git a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx index 5d2178002..c7409f5ae 100644 --- a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx +++ b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx @@ -4,7 +4,7 @@ import type { ThreadManagerState } from 'stream-chat'; import { Icon } from '../icons'; import { useChatContext } from '../../../context'; -import { useStateStore } from '../hooks/useStateStore'; +import { useStateStore } from '../../../store'; const selector = (nextValue: ThreadManagerState) => [nextValue.unseenThreadIds] as const; diff --git a/src/components/Threads/hooks/useThreadManagerState.ts b/src/components/Threads/hooks/useThreadManagerState.ts index 1ee2e85b2..18ac8c7fd 100644 --- a/src/components/Threads/hooks/useThreadManagerState.ts +++ b/src/components/Threads/hooks/useThreadManagerState.ts @@ -1,6 +1,6 @@ import { useChatContext } from 'context'; -import { useStateStore } from './useStateStore'; import { ThreadManagerState } from 'stream-chat'; +import { useStateStore } from '../../../store'; export const useThreadManagerState = ( selector: (nextValue: ThreadManagerState) => T, diff --git a/src/components/Threads/hooks/useThreadState.ts b/src/components/Threads/hooks/useThreadState.ts index be02838ef..f6d8eb7a8 100644 --- a/src/components/Threads/hooks/useThreadState.ts +++ b/src/components/Threads/hooks/useThreadState.ts @@ -1,7 +1,7 @@ import { ThreadState } from 'stream-chat'; -import { useStateStore } from './useStateStore'; import { useThreadListItemContext } from '../ThreadList'; import { useThreadContext } from '../ThreadContext'; +import { useStateStore } from '../../../store/'; /** * @description returns thread state, prioritizes `ThreadListItemContext` falls back to `ThreadContext` if not former is not present diff --git a/src/components/Threads/index.ts b/src/components/Threads/index.ts index 7347139bd..454098f8c 100644 --- a/src/components/Threads/index.ts +++ b/src/components/Threads/index.ts @@ -1,3 +1,2 @@ export * from './ThreadContext'; export * from './ThreadList'; -export * from './hooks/useStateStore'; diff --git a/src/index.ts b/src/index.ts index e5eb9f321..b86ce062d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export * from './components'; export * from './context'; export * from './i18n'; +export * from './store'; export * from './types'; export * from './utils'; diff --git a/src/store/hooks/index.ts b/src/store/hooks/index.ts new file mode 100644 index 000000000..5a67cce00 --- /dev/null +++ b/src/store/hooks/index.ts @@ -0,0 +1 @@ +export * from './useStateStore'; diff --git a/src/components/Threads/hooks/useStateStore.ts b/src/store/hooks/useStateStore.ts similarity index 100% rename from src/components/Threads/hooks/useStateStore.ts rename to src/store/hooks/useStateStore.ts diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 000000000..4cc90d02b --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1 @@ +export * from './hooks'; From 4f120a4a32ff8b523232f299ac6e25df16eee2fe Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 11 Sep 2024 12:14:16 +0200 Subject: [PATCH 23/29] refactor: remove openDialogCount from DialogManager --- src/components/Dialog/DialogManager.ts | 34 ++--- src/components/Dialog/DialogPortal.tsx | 21 +-- .../Dialog/__tests__/DialogsManager.test.js | 144 +++++++++--------- src/components/Dialog/hooks/useDialog.ts | 43 +++--- 4 files changed, 118 insertions(+), 124 deletions(-) diff --git a/src/components/Dialog/DialogManager.ts b/src/components/Dialog/DialogManager.ts index 479dd63c4..320519eef 100644 --- a/src/components/Dialog/DialogManager.ts +++ b/src/components/Dialog/DialogManager.ts @@ -1,11 +1,11 @@ import { StateStore } from 'stream-chat'; -type DialogId = string; - -export type GetOrCreateParams = { +export type GetOrCreateDialogParams = { id: DialogId; }; +type DialogId = string; + export type Dialog = { close: () => void; id: DialogId; @@ -16,29 +16,34 @@ export type Dialog = { toggleSingle: () => void; }; -type DialogInitOptions = { +export type DialogManagerOptions = { id?: string; }; type Dialogs = Record; -type DialogManagerState = { +export type DialogManagerState = { dialogsById: Dialogs; - openDialogCount: number; }; export class DialogManager { id: string; state = new StateStore({ dialogsById: {}, - openDialogCount: 0, }); - constructor({ id }: DialogInitOptions = {}) { + constructor({ id }: DialogManagerOptions = {}) { this.id = id ?? new Date().getTime().toString(); } - getOrCreate({ id }: GetOrCreateParams) { + get openDialogCount() { + return Object.values(this.state.getLatestValue().dialogsById).reduce((count, dialog) => { + if (dialog.isOpen) return count + 1; + return count; + }, 0); + } + + getOrCreate({ id }: GetOrCreateDialogParams) { let dialog = this.state.getLatestValue().dialogsById[id]; if (!dialog) { dialog = { @@ -68,7 +73,7 @@ export class DialogManager { return dialog; } - open(params: GetOrCreateParams, closeRest?: boolean) { + open(params: GetOrCreateDialogParams, closeRest?: boolean) { const dialog = this.getOrCreate(params); if (dialog.isOpen) return; if (closeRest) { @@ -77,7 +82,6 @@ export class DialogManager { this.state.next((current) => ({ ...current, dialogsById: { ...current.dialogsById, [dialog.id]: { ...dialog, isOpen: true } }, - openDialogCount: ++current.openDialogCount, })); } @@ -87,7 +91,6 @@ export class DialogManager { this.state.next((current) => ({ ...current, dialogsById: { ...current.dialogsById, [dialog.id]: { ...dialog, isOpen: false } }, - openDialogCount: --current.openDialogCount, })); } @@ -95,7 +98,7 @@ export class DialogManager { Object.values(this.state.getLatestValue().dialogsById).forEach((dialog) => dialog.close()); } - toggleOpen(params: GetOrCreateParams) { + toggleOpen(params: GetOrCreateDialogParams) { if (this.state.getLatestValue().dialogsById[params.id]?.isOpen) { this.close(params.id); } else { @@ -103,7 +106,7 @@ export class DialogManager { } } - toggleOpenSingle(params: GetOrCreateParams) { + toggleOpenSingle(params: GetOrCreateDialogParams) { if (this.state.getLatestValue().dialogsById[params.id]?.isOpen) { this.close(params.id); } else { @@ -122,9 +125,6 @@ export class DialogManager { return { ...current, dialogsById: newDialogs, - openDialogCount: - current.openDialogCount && - (dialog.isOpen ? current.openDialogCount - 1 : current.openDialogCount), }; }); } diff --git a/src/components/Dialog/DialogPortal.tsx b/src/components/Dialog/DialogPortal.tsx index eb3e912c1..e9bb63de7 100644 --- a/src/components/Dialog/DialogPortal.tsx +++ b/src/components/Dialog/DialogPortal.tsx @@ -1,24 +1,11 @@ -import React, { PropsWithChildren, useEffect, useLayoutEffect, useState } from 'react'; +import React, { PropsWithChildren, useLayoutEffect, useState } from 'react'; import { createPortal } from 'react-dom'; -import { useDialogIsOpen } from './hooks'; +import { useDialogIsOpen, useOpenedDialogCount } from './hooks'; import { useDialogManager } from '../../context'; export const DialogPortalDestination = () => { const { dialogManager } = useDialogManager(); - const [shouldRender, setShouldRender] = useState( - !!dialogManager.state.getLatestValue().openDialogCount, - ); - - useEffect( - () => - dialogManager.state.subscribeWithSelector( - ({ openDialogCount }) => [openDialogCount], - ([openDialogCount]) => { - setShouldRender(openDialogCount > 0); - }, - ), - [dialogManager], - ); + const openedDialogCount = useOpenedDialogCount(); return (
{ onClick={() => dialogManager.closeAll()} style={ { - '--str-chat__dialog-overlay-height': shouldRender ? '100%' : '0', + '--str-chat__dialog-overlay-height': openedDialogCount > 0 ? '100%' : '0', } as React.CSSProperties } >
diff --git a/src/components/Dialog/__tests__/DialogsManager.test.js b/src/components/Dialog/__tests__/DialogsManager.test.js index 23895e8c5..9c86e105e 100644 --- a/src/components/Dialog/__tests__/DialogsManager.test.js +++ b/src/components/Dialog/__tests__/DialogsManager.test.js @@ -5,20 +5,20 @@ const dialogId = 'dialogId'; describe('DialogManager', () => { it('initiates with provided options', () => { const id = 'XX'; - const dm = new DialogManager({ id }); - expect(dm.id).toBe(id); + const dialogManager = new DialogManager({ id }); + expect(dialogManager.id).toBe(id); }); it('initiates with default options', () => { const mockedId = '12345'; const spy = jest.spyOn(Date.prototype, 'getTime').mockReturnValueOnce(mockedId); - const dm = new DialogManager(); - expect(dm.id).toBe(mockedId); + const dialogManager = new DialogManager(); + expect(dialogManager.id).toBe(mockedId); spy.mockRestore(); }); it('creates a new closed dialog', () => { - const dm = new DialogManager(); - expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(0); - expect(dm.getOrCreate({ id: dialogId })).toMatchObject({ + const dialogManager = new DialogManager(); + expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(0); + expect(dialogManager.getOrCreate({ id: dialogId })).toMatchObject({ close: expect.any(Function), id: 'dialogId', isOpen: false, @@ -27,27 +27,27 @@ describe('DialogManager', () => { toggle: expect.any(Function), toggleSingle: expect.any(Function), }); - expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(1); - expect(dm.state.getLatestValue().openDialogCount).toBe(0); + expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(1); + expect(dialogManager.openDialogCount).toBe(0); }); it('retrieves an existing dialog', () => { - const dm = new DialogManager(); - dm.state.next((current) => ({ + const dialogManager = new DialogManager(); + dialogManager.state.next((current) => ({ ...current, dialogsById: { ...current.dialogsById, [dialogId]: { id: dialogId, isOpen: true } }, })); - expect(dm.getOrCreate({ id: dialogId })).toMatchObject({ + expect(dialogManager.getOrCreate({ id: dialogId })).toMatchObject({ id: 'dialogId', isOpen: true, }); - expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(1); + expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(1); }); it('creates a dialog if it does not exist on open', () => { - const dm = new DialogManager(); - dm.open({ id: dialogId }); - expect(dm.state.getLatestValue().dialogsById[dialogId]).toMatchObject({ + const dialogManager = new DialogManager(); + dialogManager.open({ id: dialogId }); + expect(dialogManager.state.getLatestValue().dialogsById[dialogId]).toMatchObject({ close: expect.any(Function), id: 'dialogId', isOpen: true, @@ -56,92 +56,92 @@ describe('DialogManager', () => { toggle: expect.any(Function), toggleSingle: expect.any(Function), }); - expect(dm.state.getLatestValue().openDialogCount).toBe(1); + expect(dialogManager.openDialogCount).toBe(1); }); it('opens existing dialog', () => { - const dm = new DialogManager(); - dm.getOrCreate({ id: dialogId }); - dm.open({ id: dialogId }); - expect(dm.state.getLatestValue().dialogsById[dialogId].isOpen).toBeTruthy(); - expect(dm.state.getLatestValue().openDialogCount).toBe(1); + const dialogManager = new DialogManager(); + dialogManager.getOrCreate({ id: dialogId }); + dialogManager.open({ id: dialogId }); + expect(dialogManager.state.getLatestValue().dialogsById[dialogId].isOpen).toBeTruthy(); + expect(dialogManager.openDialogCount).toBe(1); }); it('does not open already open dialog', () => { - const dm = new DialogManager(); - dm.getOrCreate({ id: dialogId }); - dm.open({ id: dialogId }); - dm.open({ id: dialogId }); - expect(dm.state.getLatestValue().openDialogCount).toBe(1); + const dialogManager = new DialogManager(); + dialogManager.getOrCreate({ id: dialogId }); + dialogManager.open({ id: dialogId }); + dialogManager.open({ id: dialogId }); + expect(dialogManager.openDialogCount).toBe(1); }); it('closes all other dialogsById before opening the target', () => { - const dm = new DialogManager(); - dm.open({ id: 'xxx' }); - dm.open({ id: 'yyy' }); - expect(dm.state.getLatestValue().openDialogCount).toBe(2); - dm.open({ id: dialogId }, true); - const dialogs = dm.state.getLatestValue().dialogsById; + const dialogManager = new DialogManager(); + dialogManager.open({ id: 'xxx' }); + dialogManager.open({ id: 'yyy' }); + expect(dialogManager.openDialogCount).toBe(2); + dialogManager.open({ id: dialogId }, true); + const dialogs = dialogManager.state.getLatestValue().dialogsById; expect(dialogs.xxx.isOpen).toBeFalsy(); expect(dialogs.yyy.isOpen).toBeFalsy(); - expect(dm.state.getLatestValue().dialogsById[dialogId].isOpen).toBeTruthy(); - expect(dm.state.getLatestValue().openDialogCount).toBe(1); + expect(dialogManager.state.getLatestValue().dialogsById[dialogId].isOpen).toBeTruthy(); + expect(dialogManager.openDialogCount).toBe(1); }); it('closes opened dialog', () => { - const dm = new DialogManager(); - dm.open({ id: dialogId }); - dm.close(dialogId); - expect(dm.state.getLatestValue().dialogsById[dialogId].isOpen).toBeFalsy(); - expect(dm.state.getLatestValue().openDialogCount).toBe(0); + const dialogManager = new DialogManager(); + dialogManager.open({ id: dialogId }); + dialogManager.close(dialogId); + expect(dialogManager.state.getLatestValue().dialogsById[dialogId].isOpen).toBeFalsy(); + expect(dialogManager.openDialogCount).toBe(0); }); it('does not close already closed dialog', () => { - const dm = new DialogManager(); - dm.open({ id: 'xxx' }); - dm.open({ id: dialogId }); - dm.close(dialogId); - dm.close(dialogId); - expect(dm.state.getLatestValue().openDialogCount).toBe(1); + const dialogManager = new DialogManager(); + dialogManager.open({ id: 'xxx' }); + dialogManager.open({ id: dialogId }); + dialogManager.close(dialogId); + dialogManager.close(dialogId); + expect(dialogManager.openDialogCount).toBe(1); }); it('toggles the open state of a dialog', () => { - const dm = new DialogManager(); - dm.open({ id: 'xxx' }); - dm.open({ id: 'yyy' }); - dm.toggleOpen({ id: dialogId }); - expect(dm.state.getLatestValue().openDialogCount).toBe(3); - dm.toggleOpen({ id: dialogId }); - expect(dm.state.getLatestValue().openDialogCount).toBe(2); + const dialogManager = new DialogManager(); + dialogManager.open({ id: 'xxx' }); + dialogManager.open({ id: 'yyy' }); + dialogManager.toggleOpen({ id: dialogId }); + expect(dialogManager.openDialogCount).toBe(3); + dialogManager.toggleOpen({ id: dialogId }); + expect(dialogManager.openDialogCount).toBe(2); }); it('keeps single opened dialog when the toggling open dialog state', () => { - const dm = new DialogManager(); + const dialogManager = new DialogManager(); - dm.open({ id: 'xxx' }); - dm.open({ id: 'yyy' }); - dm.toggleOpenSingle({ id: dialogId }); - expect(dm.state.getLatestValue().openDialogCount).toBe(1); + dialogManager.open({ id: 'xxx' }); + dialogManager.open({ id: 'yyy' }); + dialogManager.toggleOpenSingle({ id: dialogId }); + expect(dialogManager.openDialogCount).toBe(1); - dm.toggleOpenSingle({ id: dialogId }); - expect(dm.state.getLatestValue().openDialogCount).toBe(0); + dialogManager.toggleOpenSingle({ id: dialogId }); + expect(dialogManager.openDialogCount).toBe(0); }); it('removes a dialog', () => { - const dm = new DialogManager(); - dm.getOrCreate({ id: dialogId }); - dm.open({ id: dialogId }); - dm.remove(dialogId); - expect(dm.state.getLatestValue().openDialogCount).toBe(0); - expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(0); + const dialogManager = new DialogManager(); + dialogManager.getOrCreate({ id: dialogId }); + dialogManager.open({ id: dialogId }); + dialogManager.remove(dialogId); + expect(dialogManager.openDialogCount).toBe(0); + expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(0); }); it('handles attempt to remove non-existent dialog', () => { - const dm = new DialogManager(); - dm.getOrCreate({ id: dialogId }); - dm.open({ id: dialogId }); - dm.remove('xxx'); - expect(dm.state.getLatestValue().openDialogCount).toBe(1); - expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(1); + const dialogManager = new DialogManager(); + dialogManager.getOrCreate({ id: dialogId }); + dialogManager.open({ id: dialogId }); + dialogManager.remove('xxx'); + expect(dialogManager.openDialogCount).toBe(1); + expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(1); }); }); diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts index f3ab3ab39..9fc293b12 100644 --- a/src/components/Dialog/hooks/useDialog.ts +++ b/src/components/Dialog/hooks/useDialog.ts @@ -1,8 +1,10 @@ -import { useEffect, useState } from 'react'; -import { useDialogManager } from '../../../context/DialogManagerContext'; -import type { GetOrCreateParams } from '../DialogManager'; +import { useCallback, useEffect } from 'react'; +import { useDialogManager } from '../../../context'; +import { useStateStore } from '../../../store'; -export const useDialog = ({ id }: GetOrCreateParams) => { +import type { DialogManagerState, GetOrCreateDialogParams } from '../DialogManager'; + +export const useDialog = ({ id }: GetOrCreateDialogParams) => { const { dialogManager } = useDialogManager(); useEffect( @@ -15,21 +17,26 @@ export const useDialog = ({ id }: GetOrCreateParams) => { return dialogManager.getOrCreate({ id }); }; -export const useDialogIsOpen = (id: string, source?: string) => { +export const useDialogIsOpen = (id: string) => { const { dialogManager } = useDialogManager(); - const [open, setOpen] = useState(false); - - useEffect( - () => - dialogManager.state.subscribeWithSelector( - ({ dialogsById }) => [!!dialogsById[id]?.isOpen], - ([isOpen]) => { - setOpen(isOpen); - }, - // id, - ), - [dialogManager, id, source], + const dialogIsOpenSelector = useCallback( + ({ dialogsById }: DialogManagerState) => [!!dialogsById[id]?.isOpen], + [id], ); + return useStateStore(dialogManager.state, dialogIsOpenSelector)[0]; +}; + +const openedDialogCountSelector = (nextValue: DialogManagerState) => [ + Object.values(nextValue.dialogsById).reduce((count, dialog) => { + if (dialog.isOpen) return count + 1; + return count; + }, 0), +]; - return open; +export const useOpenedDialogCount = () => { + const { dialogManager } = useDialogManager(); + return useStateStore( + dialogManager.state, + openedDialogCountSelector, + )[0]; }; From a344533da7c88c2997dfcf0d9312d42a15f99a0e Mon Sep 17 00:00:00 2001 From: MartinCupela <32706194+MartinCupela@users.noreply.github.com> Date: Wed, 11 Sep 2024 12:36:36 +0200 Subject: [PATCH 24/29] refactor: apply suggestions about declaring state selector Co-authored-by: Anton Arnautov <43254280+arnautov-anton@users.noreply.github.com> --- src/components/Dialog/hooks/useDialog.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts index 9fc293b12..d0387ab9c 100644 --- a/src/components/Dialog/hooks/useDialog.ts +++ b/src/components/Dialog/hooks/useDialog.ts @@ -20,23 +20,21 @@ export const useDialog = ({ id }: GetOrCreateDialogParams) => { export const useDialogIsOpen = (id: string) => { const { dialogManager } = useDialogManager(); const dialogIsOpenSelector = useCallback( - ({ dialogsById }: DialogManagerState) => [!!dialogsById[id]?.isOpen], + ({ dialogsById }: DialogManagerState) => [!!dialogsById[id]?.isOpen] as const, [id], ); - return useStateStore(dialogManager.state, dialogIsOpenSelector)[0]; + return useStateStore(dialogManager.state, dialogIsOpenSelector)[0]; }; -const openedDialogCountSelector = (nextValue: DialogManagerState) => [ - Object.values(nextValue.dialogsById).reduce((count, dialog) => { - if (dialog.isOpen) return count + 1; - return count; - }, 0), -]; +const openedDialogCountSelector = (nextValue: DialogManagerState) => + [ + Object.values(nextValue.dialogsById).reduce((count, dialog) => { + if (dialog.isOpen) return count + 1; + return count; + }, 0), + ] as const; export const useOpenedDialogCount = () => { const { dialogManager } = useDialogManager(); - return useStateStore( - dialogManager.state, - openedDialogCountSelector, - )[0]; + return useStateStore(dialogManager.state, openedDialogCountSelector)[0]; }; From a212d2779514c917638799b76ee5cf61933863e2 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 11 Sep 2024 15:26:17 +0200 Subject: [PATCH 25/29] fix: remove unsupported onClick prop from ReactionListProps --- src/components/Message/__tests__/MessageOptions.test.js | 8 +------- src/components/Message/__tests__/MessageText.test.js | 1 - src/components/Reactions/ReactionsList.tsx | 4 ---- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/components/Message/__tests__/MessageOptions.test.js b/src/components/Message/__tests__/MessageOptions.test.js index 1b2162fa0..b744bc2ad 100644 --- a/src/components/Message/__tests__/MessageOptions.test.js +++ b/src/components/Message/__tests__/MessageOptions.test.js @@ -34,7 +34,6 @@ const defaultMessageProps = { initialMessage: false, message: generateMessage(), messageActions: Object.keys(MESSAGE_ACTIONS), - onReactionListClick: () => {}, threadList: false, }; const defaultOptionsProps = {}; @@ -70,12 +69,7 @@ async function renderMessageOptions({ value={{ Attachment, // eslint-disable-next-line react/display-name - Message: () => ( - - ), + Message: () => , reactionOptions: defaultReactionOptions, }} > diff --git a/src/components/Message/__tests__/MessageText.test.js b/src/components/Message/__tests__/MessageText.test.js index 8171eadac..4f561c2a9 100644 --- a/src/components/Message/__tests__/MessageText.test.js +++ b/src/components/Message/__tests__/MessageText.test.js @@ -43,7 +43,6 @@ const onMentionsClickMock = jest.fn(); const defaultProps = { initialMessage: false, message: generateMessage(), - onReactionListClick: () => {}, threadList: false, }; diff --git a/src/components/Reactions/ReactionsList.tsx b/src/components/Reactions/ReactionsList.tsx index d5974854a..c03025e44 100644 --- a/src/components/Reactions/ReactionsList.tsx +++ b/src/components/Reactions/ReactionsList.tsx @@ -4,8 +4,6 @@ import clsx from 'clsx'; import type { ReactionGroupResponse, ReactionResponse } from 'stream-chat'; import { useProcessReactions } from './hooks/useProcessReactions'; - -import type { ReactEventHandler } from '../Message/types'; import type { DefaultStreamChatGenerics } from '../../types/types'; import type { ReactionOptions } from './reactionOptions'; import type { ReactionDetailsComparator, ReactionsComparator, ReactionType } from './types'; @@ -18,8 +16,6 @@ export type ReactionsListProps< > = Partial< Pick, 'handleFetchReactions' | 'reactionDetailsSort'> > & { - /** Custom on click handler for an individual reaction, defaults to `onReactionListClick` from the `MessageContext` */ - onClick?: ReactEventHandler; /** An array of the own reaction objects to distinguish own reactions visually */ own_reactions?: ReactionResponse[]; /** From 993200133cbaf3420cddd30bf333ff3385bb4773 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 11 Sep 2024 15:27:42 +0200 Subject: [PATCH 26/29] docs: remove references to removed props --- .../components/contexts/message-context.mdx | 24 ------------------- .../message-components/message-ui.mdx | 24 ------------------- .../message-components/reactions.mdx | 8 ------- .../message-components/ui-components.mdx | 16 ------------- 4 files changed, 72 deletions(-) diff --git a/docusaurus/docs/React/components/contexts/message-context.mdx b/docusaurus/docs/React/components/contexts/message-context.mdx index 35cce3268..d18a8d436 100644 --- a/docusaurus/docs/React/components/contexts/message-context.mdx +++ b/docusaurus/docs/React/components/contexts/message-context.mdx @@ -304,14 +304,6 @@ Function that runs on hover of an @mention in a message. | ----------------------------------------------------------- | | (event: React.BaseSyntheticEvent) => Promise \| void | -### onReactionListClick - -Function that runs on click of the reactions list component. - -| Type | -| ----------------------------------------------------------- | -| (event: React.BaseSyntheticEvent) => Promise \| void | - ### onUserClick Function that runs on click of a user avatar. @@ -336,14 +328,6 @@ The user roles allowed to pin messages in various channel types (deprecated in f | ------ | ------------------------------------------------------------------------- | | object | | -### reactionSelectorRef - -Ref to be placed on the reaction selector component. - -| Type | -| --------------------------------------- | -| React.MutableRefObject | - ### readBy An array of users that have read the current message. @@ -368,14 +352,6 @@ Function to toggle the editing state on a message. | ----------------------------------------------------------- | | (event: React.BaseSyntheticEvent) => Promise \| void | -### showDetailedReactions - -When true, show the reactions list component. - -| Type | -| ------- | -| boolean | - ### reactionDetailsSort Sort options to provide to a reactions query. Affects the order of reacted users in the default reactions modal. diff --git a/docusaurus/docs/React/components/message-components/message-ui.mdx b/docusaurus/docs/React/components/message-components/message-ui.mdx index e8ad602d3..7a6bdd4ea 100644 --- a/docusaurus/docs/React/components/message-components/message-ui.mdx +++ b/docusaurus/docs/React/components/message-components/message-ui.mdx @@ -397,14 +397,6 @@ Function that runs on hover of an @mention in a message (overrides the function | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | (event: React.BaseSyntheticEvent) => Promise \| void | [MessageContextValue['onMentionsHoverMessage']](../contexts/channel-action-context.mdx#onmentionshovermessage) | -### onReactionListClick - -Function that runs on click of the reactions list component (overrides the function stored in `MessageContext`). - -| Type | Default | -| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| (event: React.BaseSyntheticEvent) => Promise \| void | [MessageContextValue['onReactionListClick']](../contexts/channel-action-context.mdx#onreactionlistclick) | - ### onUserClick Function that runs on click of a user avatar (overrides the function stored in `MessageContext`). @@ -429,14 +421,6 @@ The user roles allowed to pin messages in various channel types (deprecated in f | ------ | -------------------------------------------------------------------------------------------------------------------- | | object | [defaultPinPermissions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/utils.tsx) | -### reactionSelectorRef - -Ref to be placed on the reaction selector component (overrides the ref stored in `MessageContext`). - -| Type | -| --------------------------------------- | -| React.MutableRefObject | - ### readBy An array of users that have read the current message (overrides the value stored in `MessageContext`). @@ -461,14 +445,6 @@ Function to toggle the editing state on a message (overrides the function stored | ----------------------------------------------------------- | | (event: React.BaseSyntheticEvent) => Promise \| void | -### showDetailedReactions - -When true, show the reactions list component (overrides the value stored in `MessageContext`). - -| Type | -| ------- | -| boolean | - ### threadList If true, indicates that the current `MessageList` component is part of a `Thread` (overrides the value stored in `MessageContext`). diff --git a/docusaurus/docs/React/components/message-components/reactions.mdx b/docusaurus/docs/React/components/message-components/reactions.mdx index 1f3a79613..68cade800 100644 --- a/docusaurus/docs/React/components/message-components/reactions.mdx +++ b/docusaurus/docs/React/components/message-components/reactions.mdx @@ -151,14 +151,6 @@ const MyCustomReactionsList = (props) => { }; ``` -### onClick - -Custom on click handler for an individual reaction in the list (overrides the function coming from `MessageContext`). - -| Type | Default | -| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| (event: React.BaseSyntheticEvent) => Promise \| void | [MessageContextValue['onReactionListClick']](../contexts/message-context.mdx#onreactionlistclick) | - ### own_reactions An array of the own reaction objects to distinguish own reactions visually (overrides `message.own_reactions` from `MessageContext`). diff --git a/docusaurus/docs/React/components/message-components/ui-components.mdx b/docusaurus/docs/React/components/message-components/ui-components.mdx index dd88e3c80..ff1a2e850 100644 --- a/docusaurus/docs/React/components/message-components/ui-components.mdx +++ b/docusaurus/docs/React/components/message-components/ui-components.mdx @@ -126,14 +126,6 @@ The `StreamChat` message object, which provides necessary data to the underlying | ------ | | object | -### messageWrapperRef - -React mutable ref placed on the message root `div`. It is forwarded by `MessageOptions` down to `MessageActions` ([see the example](../../guides/theming/message-ui.mdx)). - -| Type | -| -------------------------------- | -| React.RefObject | - ### mine Function that returns whether the message was sent by the connected user. @@ -178,14 +170,6 @@ Function that opens a [`Thread`](../core-components/thread.mdx) on a message (ov | ----------------------------------------------------------- | | (event: React.BaseSyntheticEvent) => Promise \| void | -### messageWrapperRef - -React mutable ref that can be placed on the message root `div`. `MessageOptions` component forwards this prop to [`MessageActions`](#messageactions-props) component ([see the example](../../guides/theming/message-ui.mdx)). - -| Type | -| -------------------------------- | -| React.RefObject | - ### ReactionIcon Custom component rendering the icon used in a message options button invoking reactions selector for a given message. From 7b32ea366511e250c629120d6f450c77256c85e1 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 11 Sep 2024 15:28:27 +0200 Subject: [PATCH 27/29] docs: add dialog management guide and migration guide --- .../docs/React/guides/dialog-management.mdx | 108 ++++++++++++++++++ .../React/release-guides/upgrade-to-v12.mdx | 32 ++++++ docusaurus/sidebars-react.json | 3 +- 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 docusaurus/docs/React/guides/dialog-management.mdx diff --git a/docusaurus/docs/React/guides/dialog-management.mdx b/docusaurus/docs/React/guides/dialog-management.mdx new file mode 100644 index 000000000..8031c6aad --- /dev/null +++ b/docusaurus/docs/React/guides/dialog-management.mdx @@ -0,0 +1,108 @@ +--- +id: dialog-management +title: Dialog Management +--- + +This article presents the API the integrators can use to toggle display dialogs in their UIs. The default components that are displayed as dialogs are: + +- `ReactionSelector` - allows users to post reactions / emojis to a message +- `MessageActionsBox` - allows user to select from a list of permitted message actions + +The dialog management following this guide is enabled within `MessageList` and `VirtualizedMessageList`. + +## Setup dialog display + +There are two actors in the play. The first one is the component that requests the dialog to be closed or open and the other is the component that renders the dialog. We will start with demonstrating how to properly render a component in a dialog. + +### Rendering a dialog + +Component we want to be rendered as a floating dialog should be wrapped inside `DialogAnchor`: + +```tsx +import React, { ElementRef, useRef } from 'react'; +import { DialogAnchor } from 'stream-chat-react'; + +import { ComponentToDisplayOnDialog } from './ComponentToDisplayOnDialog'; +import { generateUniqueId } from './generateUniqueId'; + +const Container = () => { + // DialogAnchor needs a reference to the element that will toggle the open state. Based on this reference the dialog positioning is calculated + const buttonRef = useRef>(null); + // providing the dialog is necessary for the dialog to be retrieved from anywhere in the DialogManagerProviderContext + const dialogId = generateUniqueId(); + + return ( + <> + + + + + ); +}; +``` + +### Controlling a dialog's display + +The dialog display is controlled via Dialog API. You can access the API via `useDialog()` hook. + +```tsx +import React, { ElementRef, useRef } from 'react'; +import { DialogAnchor, useDialog, useDialogIsOpen } from 'stream-chat-react'; + +import { ComponentToDisplayOnDialog } from './ComponentToDisplayOnDialog'; +import { generateUniqueId } from './generateUniqueId'; + +const Container = () => { + const buttonRef = useRef>(null); + const dialogId = generateUniqueId(); + // access the dialog controller which provides the dialog API + const dialog = useDialog({ id: dialogId }); + // subscribe to dialog open state changes + const dialogIsOpen = useDialogIsOpen(dialogId); + + return ( + <> + + + + + + ); +}; +``` + +### Dialog API + +Dialog can be controlled via `Dialog` object retrieved using `useDialog()` hook. The hook returns an object with the following API: + +- `dialog.open()` - opens the dialog +- `dialog.close()` - closes the dialog +- `dialog.toggleOpen()` - flips the dialog open state and does not close any other dialog that could be open +- `dialog.toggleOpenSingle()` - flips the open state and does close any other dialog that could be open +- `dialog.remove()` - removes the dialog object reference from the state (primarily for cleanup purposes) + +Every `Dialog` object carries its own `id` and `isOpen` flag. + +### Dialog utility hooks + +There are the following utility hooks that can be used to subscribe to state changes or access a given dialog: + +- `useDialogIsOpen(id: string)` - allows to observe the open state of a particular `Dialog` instance +- `useDialog({ id }: GetOrCreateDialogParams)` - retrieves a dialog object that exposes API to manage it +- `useOpenedDialogCount()` - allows to observe changes in the open dialog count + +### Custom dialog management context + +Those who would like to render dialogs outside the `MessageList` and `VirtualizedMessageList`, will need to create a dialog management context using `DialogManagerProvider`. + +```tsx +import { DialogManagerProvider } from 'stream-chat-react'; + +const Container = () => { + return ; +}; +``` + +Now the children of `DialogAnchor` will be anchored to the parent `DialogManagerProvider`. diff --git a/docusaurus/docs/React/release-guides/upgrade-to-v12.mdx b/docusaurus/docs/React/release-guides/upgrade-to-v12.mdx index f5d52acbc..0a136d838 100644 --- a/docusaurus/docs/React/release-guides/upgrade-to-v12.mdx +++ b/docusaurus/docs/React/release-guides/upgrade-to-v12.mdx @@ -117,6 +117,38 @@ import { encodeToMp3 } from 'stream-chat-react/mp3-encoder'; ::: +## Unified dialog management + +Dialogs will be managed centrally. At the moment, this applies to display of `ReactionSelector` and `MessageActionsBox`. They will be displayed on a transparent overlay that prevents users from opening other dialogs in the message list. Once an option from a dialog is selected or the overlay is clicked, the dialog will disappear. This adjust brings new API and removes some properties from `MessageContextValue`. + +### Removed properties from MessageContextValue + +- `isReactionEnabled` - served to signal the permission to send reactions by the current user in a given channel. With the current permissions implementation, the permission can be determined by doing the following: + +``` +import { useMessageContext } from 'stream-chat-react'; + +const { getMessageActions } = useMessageContext(); +const messageActions = getMessageActions(); +const canReact = messageActions.includes(MESSAGE_ACTIONS.react); +``` + +- `onReactionListClick` - handler function that toggled the open state of `ReactionSelector` represented by another removed value - `showDetailedReactions` +- `showDetailedReactions` - flag used to decide, whether the reaction selector should be shown or not +- `reactionSelectorRef` - ref to the root of the reaction selector component (served to control the display of the component) + +Also prop `messageWrapperRef` was removed as part of the change from `MessageOptions` and `MessageActions` props. + +On the other hand, the `Message` prop (configuration parameter) `closeReactionSelectorOnClick` is now available in the `MessageContextValue`. + +:::important +If you used any of these values in your customizations, please make sure to adjust your implementation according to the newly recommended use of Dialog API in [Dialog management guide](../../guides/dialog-management). +::: + +### New dialog management API + +To learn about the new API, please, take a look at our [Dialog management guide](../../guides/dialog-management). + ## EmojiPickerIcon extraction to emojis plugin The default `EmojiPickerIcon` has been moved to emojis plugin from which we already import `EmojiPicker` component. diff --git a/docusaurus/sidebars-react.json b/docusaurus/sidebars-react.json index aaf798de7..548c89099 100644 --- a/docusaurus/sidebars-react.json +++ b/docusaurus/sidebars-react.json @@ -140,7 +140,8 @@ "guides/channel_read_state", "guides/video-integration/video-integration-stream", "guides/sdk-state-management", - "guides/date-time-formatting" + "guides/date-time-formatting", + "guides/dialog-management" ] }, { "Release Guides": ["release-guides/upgrade-to-v12", "release-guides/upgrade-to-v11", "release-guides/upgrade-to-v10"] }, From f649140b70bc6e31c9214f4401a68424fbff6888 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 13 Sep 2024 11:02:04 +0200 Subject: [PATCH 28/29] chore(docs): bump stream-chat-css to v5.0.0-rc.6 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a9b3d6f05..a2152cbc9 100644 --- a/package.json +++ b/package.json @@ -171,7 +171,7 @@ "@semantic-release/changelog": "^6.0.2", "@semantic-release/git": "^10.0.1", "@stream-io/rollup-plugin-node-builtins": "^2.1.5", - "@stream-io/stream-chat-css": "^5.0.0-rc.5", + "@stream-io/stream-chat-css": "5.0.0-rc.6", "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^13.1.1", "@testing-library/react-hooks": "^8.0.0", diff --git a/yarn.lock b/yarn.lock index 229ec0df5..92b9510f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2356,10 +2356,10 @@ crypto-browserify "^3.11.0" process-es6 "^0.11.2" -"@stream-io/stream-chat-css@^5.0.0-rc.5": - version "5.0.0-rc.5" - resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-5.0.0-rc.5.tgz#889218fc9c604b12d4b8d5895a7c96668d4b78fc" - integrity sha512-1NfgoJE5PC/i4aVspIsMaSbvh8rphpilAv6+zlBOCVQL/AAhSFt8QdHUGSTeqwzI7p6waiFk0pQ2bSWKTUpuFA== +"@stream-io/stream-chat-css@5.0.0-rc.6": + version "5.0.0-rc.6" + resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-5.0.0-rc.6.tgz#8ad9f7290150d10c4135ec3205e83569a0bce95d" + integrity sha512-tT+9glFTdA0ayyhFvpBNfcBi4wZGcr1FSiwS2aNYJrWFE0XpM4aXgq8h5bWha3mOBcQErTDHoUxRw0D/JOt69A== "@stream-io/transliterate@^1.5.5": version "1.5.5" From 7a67c034f7e43c9a1e641bbae95d2c10d16a4f88 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 16 Sep 2024 17:38:51 +0200 Subject: [PATCH 29/29] refactor: merge Dialog.toggleOpen and Dialog.toggleOpenSingle into Dialog.toggle --- .../docs/React/guides/dialog-management.mdx | 3 +- src/components/Dialog/DialogManager.ts | 32 +++++++++---------- .../Dialog/__tests__/DialogsManager.test.js | 10 +++--- .../MessageActions/MessageActions.tsx | 2 +- .../Reactions/ReactionSelectorWithButton.tsx | 2 +- 5 files changed, 22 insertions(+), 27 deletions(-) diff --git a/docusaurus/docs/React/guides/dialog-management.mdx b/docusaurus/docs/React/guides/dialog-management.mdx index 8031c6aad..f2c500115 100644 --- a/docusaurus/docs/React/guides/dialog-management.mdx +++ b/docusaurus/docs/React/guides/dialog-management.mdx @@ -79,8 +79,7 @@ Dialog can be controlled via `Dialog` object retrieved using `useDialog()` hook. - `dialog.open()` - opens the dialog - `dialog.close()` - closes the dialog -- `dialog.toggleOpen()` - flips the dialog open state and does not close any other dialog that could be open -- `dialog.toggleOpenSingle()` - flips the open state and does close any other dialog that could be open +- `dialog.toggle()` - toggles the dialog open state. Accepts boolean argument `closeAll`. If enabled closes any other dialog that would be open. - `dialog.remove()` - removes the dialog object reference from the state (primarily for cleanup purposes) Every `Dialog` object carries its own `id` and `isOpen` flag. diff --git a/src/components/Dialog/DialogManager.ts b/src/components/Dialog/DialogManager.ts index 320519eef..503adbcf2 100644 --- a/src/components/Dialog/DialogManager.ts +++ b/src/components/Dialog/DialogManager.ts @@ -12,8 +12,7 @@ export type Dialog = { isOpen: boolean | undefined; open: (zIndex?: number) => void; remove: () => void; - toggle: () => void; - toggleSingle: () => void; + toggle: (closeAll?: boolean) => void; }; export type DialogManagerOptions = { @@ -26,6 +25,16 @@ export type DialogManagerState = { dialogsById: Dialogs; }; +/** + * Keeps a map of Dialog objects. + * Dialog can be controlled via `Dialog` object retrieved using `useDialog()` hook. + * The hook returns an object with the following API: + * + * - `dialog.open()` - opens the dialog + * - `dialog.close()` - closes the dialog + * - `dialog.toggle()` - toggles the dialog open state. Accepts boolean argument closeAll. If enabled closes any other dialog that would be open. + * - `dialog.remove()` - removes the dialog object reference from the state (primarily for cleanup purposes) + */ export class DialogManager { id: string; state = new StateStore({ @@ -58,11 +67,8 @@ export class DialogManager { remove: () => { this.remove(id); }, - toggle: () => { - this.toggleOpen({ id }); - }, - toggleSingle: () => { - this.toggleOpenSingle({ id }); + toggle: (closeAll = false) => { + this.toggle({ id }, closeAll); }, }; this.state.next((current) => ({ @@ -98,19 +104,11 @@ export class DialogManager { Object.values(this.state.getLatestValue().dialogsById).forEach((dialog) => dialog.close()); } - toggleOpen(params: GetOrCreateDialogParams) { - if (this.state.getLatestValue().dialogsById[params.id]?.isOpen) { - this.close(params.id); - } else { - this.open(params); - } - } - - toggleOpenSingle(params: GetOrCreateDialogParams) { + toggle(params: GetOrCreateDialogParams, closeAll = false) { if (this.state.getLatestValue().dialogsById[params.id]?.isOpen) { this.close(params.id); } else { - this.open(params, true); + this.open(params, closeAll); } } diff --git a/src/components/Dialog/__tests__/DialogsManager.test.js b/src/components/Dialog/__tests__/DialogsManager.test.js index 9c86e105e..f27f4d846 100644 --- a/src/components/Dialog/__tests__/DialogsManager.test.js +++ b/src/components/Dialog/__tests__/DialogsManager.test.js @@ -25,7 +25,6 @@ describe('DialogManager', () => { open: expect.any(Function), remove: expect.any(Function), toggle: expect.any(Function), - toggleSingle: expect.any(Function), }); expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(1); expect(dialogManager.openDialogCount).toBe(0); @@ -54,7 +53,6 @@ describe('DialogManager', () => { open: expect.any(Function), remove: expect.any(Function), toggle: expect.any(Function), - toggleSingle: expect.any(Function), }); expect(dialogManager.openDialogCount).toBe(1); }); @@ -109,9 +107,9 @@ describe('DialogManager', () => { const dialogManager = new DialogManager(); dialogManager.open({ id: 'xxx' }); dialogManager.open({ id: 'yyy' }); - dialogManager.toggleOpen({ id: dialogId }); + dialogManager.toggle({ id: dialogId }); expect(dialogManager.openDialogCount).toBe(3); - dialogManager.toggleOpen({ id: dialogId }); + dialogManager.toggle({ id: dialogId }); expect(dialogManager.openDialogCount).toBe(2); }); @@ -120,10 +118,10 @@ describe('DialogManager', () => { dialogManager.open({ id: 'xxx' }); dialogManager.open({ id: 'yyy' }); - dialogManager.toggleOpenSingle({ id: dialogId }); + dialogManager.toggle({ id: dialogId }, true); expect(dialogManager.openDialogCount).toBe(1); - dialogManager.toggleOpenSingle({ id: dialogId }); + dialogManager.toggle({ id: dialogId }, true); expect(dialogManager.openDialogCount).toBe(0); }); diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx index aa27f68d1..02fe9be56 100644 --- a/src/components/MessageActions/MessageActions.tsx +++ b/src/components/MessageActions/MessageActions.tsx @@ -106,7 +106,7 @@ export const MessageActions = < dialog?.toggle()} ref={buttonRef} >