From 9751e15c53ccdcbf76b4c61995cb20ee0d20e37a Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 4 Sep 2024 17:44:30 +0200 Subject: [PATCH] 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';