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 c8c258dd70..f336135044 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 0000000000..31e62dcec0 --- /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 0000000000..9110b42e55 --- /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 0000000000..c15a7052d9 --- /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 0000000000..9d08c250c7 --- /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 0000000000..802fe57610 --- /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 0000000000..3bfd1c2dca --- /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 e057dceb62..b7275801df 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 194313dde2..6c93c64d08 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 9b1a388cb8..d4a6e8f080 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 0000000000..3aa2ef6488 --- /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 21c075febe..1f4f3fa85b 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 31e62dcec0..20c7434f01 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 9110b42e55..e31a3582f9 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 9860f9b7da..41dd364e71 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 3aa2ef6488..1e564e7ee0 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 f336135044..7540283099 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 30f1dea121..785982d274 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 fdbee888df..e7deb75ec0 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 9c7afa2d36..cbb350f209 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 2f786facf2..4975bd3e45 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 fb3ce9b8ad..4a6571980c 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 89a5566231..1b2b7157ce 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 9230ade0ad..b900ecf854 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 4ea760c01f..052d5d589b 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 47619c06a3..0ab1b8f655 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 785982d274..0d25667a5d 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 52e092be89..65cfe632a2 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 20c7434f01..8eac6aa759 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 41dd364e71..cdae62daa5 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 8eac6aa759..db27d8ee68 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 4a6571980c..8b93092917 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 fcac6a4b97..953fca0e85 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 cdae62daa5..1567a9f992 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 cbb350f209..1783b69af8 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 33a909521a..bc678e1a01 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 8b93092917..f3b569c56b 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 67c9cf6ae4..b076be757d 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 04a03f1c49..3b61291ff5 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 b7275801df..2565d00bb2 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 9d659e4f8b..26e6a8c238 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 d8fa2b53d6..9f2d9475b6 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 0000000000..5a083e7d22 --- /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 500d9d9e4c..0c159777e7 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 77ac4ed9bc..7a602ad5b2 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 7ee4145c07..aa27f68d12 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 13122e5e7b..04677f6564 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 275f9f5edb..ace76c8793 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 a01ced9d6f..eb3e912c13 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 cc2c999f8a..537e8c0f6a 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 77bab9acb6..95d0ac4db3 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 3bfd1c2dca..a2462dbcdd 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 f3b569c56b..718cc9522d 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 b076be757d..64f8beda8e 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 6ebe049501..1e03d80b35 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 0ea6965112..9a244c9016 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 6b30d65190..7c708d2723 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 0c159777e7..3b668ea76a 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 0000000000..b1f14126d9 --- /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 1e564e7ee0..0000000000 --- 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 1dca382914..15e3f422be 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 ace76c8793..1882eb32bb 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 1882eb32bb..479dd63c40 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 537e8c0f6a..23895e8c52 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 95d0ac4db3..f3ab3ab398 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 5d6aea889d..f14261dee9 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 f8890ea21b..3da40fcb88 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 718cc9522d..1b2162fa00 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 6ede0894a2..a02b1c7370 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 fdec3a5200..e397bd427e 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 f64ffdc86e..f1cef2dd0b 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 da9da4ea42..e778b30359 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 5d2178002a..c7409f5ae8 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 1ee2e85b29..18ac8c7fd7 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 be02838efd..f6d8eb7a89 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 7347139bd7..454098f8cc 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 e5eb9f3219..b86ce062dc 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 0000000000..5a67cce005 --- /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 0000000000..4cc90d02bd --- /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 479dd63c40..320519eef3 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 eb3e912c13..e9bb63de7f 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 23895e8c52..9c86e105e2 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 f3ab3ab398..9fc293b128 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 9fc293b128..d0387ab9c3 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 1b2162fa00..b744bc2ad6 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 8171eadac0..4f561c2a9c 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 d5974854a8..c03025e445 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 35cce32689..d18a8d4361 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 e8ad602d39..7a6bdd4ea8 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 1f3a796131..68cade8001 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 dd88e3c807..ff1a2e8504 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 0000000000..8031c6aad1 --- /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 f5d52acbc4..0a136d8387 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 aaf798de72..548c890992 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 a9b3d6f058..a2152cbc96 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 229ec0df53..92b9510f86 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 8031c6aad1..f2c5001156 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 320519eef3..503adbcf23 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 9c86e105e2..f27f4d8465 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 aa27f68d12..02fe9be563 100644 --- a/src/components/MessageActions/MessageActions.tsx +++ b/src/components/MessageActions/MessageActions.tsx @@ -106,7 +106,7 @@ export const MessageActions = < dialog?.toggle()} ref={buttonRef} >