From 9751e15c53ccdcbf76b4c61995cb20ee0d20e37a Mon Sep 17 00:00:00 2001
From: martincupela
Date: Wed, 4 Sep 2024 17:44:30 +0200
Subject: [PATCH 01/29] feat: add centralized dialog management
---
src/components/Channel/Channel.tsx | 39 +--
src/components/Dialog/DialogAnchor.tsx | 79 ++++++
src/components/Dialog/DialogPortal.tsx | 62 +++++
src/components/Dialog/DialogsManager.ts | 178 ++++++++++++
src/components/Dialog/hooks/index.ts | 1 +
src/components/Dialog/hooks/useDialog.ts | 32 +++
src/components/Dialog/index.ts | 4 +
.../Message/hooks/useReactionHandler.ts | 13 +-
.../MessageActions/MessageActions.tsx | 101 +++----
.../MessageActions/MessageActionsBox.tsx | 259 ++++++++----------
src/components/index.ts | 1 +
src/context/DialogsManagerContext.tsx | 28 ++
src/context/index.ts | 1 +
13 files changed, 575 insertions(+), 223 deletions(-)
create mode 100644 src/components/Dialog/DialogAnchor.tsx
create mode 100644 src/components/Dialog/DialogPortal.tsx
create mode 100644 src/components/Dialog/DialogsManager.ts
create mode 100644 src/components/Dialog/hooks/index.ts
create mode 100644 src/components/Dialog/hooks/useDialog.ts
create mode 100644 src/components/Dialog/index.ts
create mode 100644 src/context/DialogsManagerContext.tsx
diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx
index c8c258dd7..f33613504 100644
--- a/src/components/Channel/Channel.tsx
+++ b/src/components/Channel/Channel.tsx
@@ -76,6 +76,10 @@ import type { UnreadMessagesNotificationProps } from '../MessageList';
import { hasMoreMessagesProbably, UnreadMessagesSeparator } from '../MessageList';
import { useChannelContainerClasses } from './hooks/useChannelContainerClasses';
import { findInMsgSetByDate, findInMsgSetById, makeAddNotifications } from './utils';
+import { DateSeparator } from '../DateSeparator';
+import { DialogsManagerProvider } from '../Dialog';
+import { EventComponent } from '../EventComponent';
+import { defaultReactionOptions, ReactionOptions } from '../Reactions';
import { getChannel } from '../../utils';
import type { MessageProps } from '../Message/types';
@@ -96,9 +100,6 @@ import {
getVideoAttachmentConfiguration,
} from '../Attachment/attachment-sizing';
import type { URLEnrichmentConfig } from '../MessageInput/hooks/useLinkPreviews';
-import { defaultReactionOptions, ReactionOptions } from '../Reactions';
-import { EventComponent } from '../EventComponent';
-import { DateSeparator } from '../DateSeparator';
type ChannelPropsForwardedToComponentContext<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
@@ -1241,7 +1242,7 @@ const ChannelInner = <
],
);
- const componentContextValue: ComponentContextValue = useMemo(
+ const componentContextValue = useMemo>(
() => ({
Attachment: props.Attachment || DefaultAttachment,
AttachmentPreviewList: props.AttachmentPreviewList,
@@ -1329,20 +1330,22 @@ const ChannelInner = <
return (
-
-
-
-
-
- {dragAndDropWindow && (
- {children}
- )}
- {!dragAndDropWindow && <>{children}>}
-
-
-
-
-
+
+
+
+
+
+
+ {dragAndDropWindow && (
+ {children}
+ )}
+ {!dragAndDropWindow && <>{children}>}
+
+
+
+
+
+
);
};
diff --git a/src/components/Dialog/DialogAnchor.tsx b/src/components/Dialog/DialogAnchor.tsx
new file mode 100644
index 000000000..31e62dcec
--- /dev/null
+++ b/src/components/Dialog/DialogAnchor.tsx
@@ -0,0 +1,79 @@
+import { Placement } from '@popperjs/core';
+import React, { ComponentProps, PropsWithChildren, useEffect, useRef } from 'react';
+import { usePopper } from 'react-popper';
+import { useDialogIsOpen } from './hooks';
+import { DialogPortalEntry } from './DialogPortal';
+
+export interface DialogAnchorOptions {
+ open: boolean;
+ placement: Placement;
+ referenceElement: HTMLElement | null;
+}
+
+export function useDialogAnchor({
+ open,
+ placement,
+ referenceElement,
+}: DialogAnchorOptions) {
+ const popperElementRef = useRef(null);
+ const { attributes, styles, update } = usePopper(referenceElement, popperElementRef.current, {
+ modifiers: [
+ {
+ name: 'eventListeners',
+ options: {
+ // It's not safe to update popper position on resize and scroll, since popper's
+ // reference element might not be visible at the time.
+ resize: false,
+ scroll: false,
+ },
+ },
+ ],
+ placement,
+ });
+
+ useEffect(() => {
+ if (open) {
+ // Since the popper's reference element might not be (and usually is not) visible
+ // all the time, it's safer to force popper update before showing it.
+ update?.();
+ }
+ }, [open, update]);
+
+ return {
+ attributes,
+ popperElementRef,
+ styles,
+ };
+}
+
+type DialogAnchorProps = PropsWithChildren> & {
+ id: string;
+} & ComponentProps<'div' | 'span'>;
+
+export const DialogAnchor = ({
+ children,
+ className,
+ id,
+ placement = 'auto',
+ referenceElement = null,
+}: DialogAnchorProps) => {
+ const open = useDialogIsOpen(id);
+ const { attributes, popperElementRef, styles } = useDialogAnchor({
+ open,
+ placement,
+ referenceElement,
+ });
+
+ return (
+
+
+ {children}
+
+
+ );
+};
diff --git a/src/components/Dialog/DialogPortal.tsx b/src/components/Dialog/DialogPortal.tsx
new file mode 100644
index 000000000..9110b42e5
--- /dev/null
+++ b/src/components/Dialog/DialogPortal.tsx
@@ -0,0 +1,62 @@
+import React, { PropsWithChildren, useEffect, useLayoutEffect, useState } from 'react';
+import { createPortal } from 'react-dom';
+import type { DialogsManager } from './DialogsManager';
+import { useDialogIsOpen } from './hooks';
+import { useDialogsManager } from '../../context';
+
+export const DialogPortalDestination = () => {
+ const { dialogsManager } = useDialogsManager();
+ const [shouldRender, setShouldRender] = useState(!!dialogsManager.openDialogCount);
+ useEffect(
+ () =>
+ dialogsManager.on('openCountChange', {
+ listener: (dm: DialogsManager) => {
+ setShouldRender(dm.openDialogCount > 0);
+ },
+ }),
+ [dialogsManager],
+ );
+
+ return (
+ <>
+ dialogsManager.closeAll()}
+ style={{
+ height: '100%',
+ inset: '0',
+ overflow: 'hidden',
+ position: 'absolute',
+ width: '100%',
+ zIndex: shouldRender ? '2' : '-1',
+ }}
+ >
+
+
+ >
+ );
+};
+
+type DialogPortalEntryProps = {
+ dialogId: string;
+};
+
+export const DialogPortalEntry = ({
+ children,
+ dialogId,
+}: PropsWithChildren) => {
+ const { dialogsManager } = useDialogsManager();
+ const dialogIsOpen = useDialogIsOpen(dialogId);
+ const [portalDestination, setPortalDestination] = useState(null);
+ useLayoutEffect(() => {
+ const destination = document.querySelector(
+ `div[data-str-chat__portal-id="${dialogsManager.id}"]`,
+ );
+ if (!destination) return;
+ setPortalDestination(destination);
+ }, [dialogsManager, dialogIsOpen]);
+
+ if (!portalDestination) return null;
+
+ return createPortal(children, portalDestination);
+};
diff --git a/src/components/Dialog/DialogsManager.ts b/src/components/Dialog/DialogsManager.ts
new file mode 100644
index 000000000..c15a7052d
--- /dev/null
+++ b/src/components/Dialog/DialogsManager.ts
@@ -0,0 +1,178 @@
+type DialogId = string;
+
+export type GetOrCreateParams = {
+ id: DialogId;
+ isOpen?: boolean;
+};
+
+export type Dialog = {
+ close: () => void;
+ id: DialogId;
+ isOpen: boolean | undefined;
+ open: (zIndex?: number) => void;
+ remove: () => void;
+ toggle: () => void;
+ toggleSingle: () => void;
+};
+
+type DialogEvent = { type: 'close' | 'open' | 'openCountChange' };
+
+const dialogsManagerEvents = ['openCountChange'] as const;
+type DialogsManagerEvent = { type: typeof dialogsManagerEvents[number] };
+
+type DialogEventHandler = (dialog: Dialog) => void;
+type DialogsManagerEventHandler = (dialogsManager: DialogsManager) => void;
+
+type DialogInitOptions = {
+ id?: string;
+};
+
+const noop = (): void => undefined;
+
+export class DialogsManager {
+ id: string;
+ openDialogCount = 0;
+ dialogs: Record = {};
+ private dialogEventListeners: Record<
+ DialogId,
+ Partial>
+ > = {};
+ private dialogsManagerEventListeners: Record<
+ DialogsManagerEvent['type'],
+ DialogsManagerEventHandler[]
+ > = { openCountChange: [] };
+
+ constructor({ id }: DialogInitOptions = {}) {
+ this.id = id ?? new Date().getTime().toString();
+ }
+
+ getOrCreate({ id, isOpen = false }: GetOrCreateParams) {
+ let dialog = this.dialogs[id];
+ if (!dialog) {
+ dialog = {
+ close: () => {
+ this.close(id);
+ },
+ id,
+ isOpen,
+ open: () => {
+ this.open({ id });
+ },
+ remove: () => {
+ this.remove(id);
+ },
+ toggle: () => {
+ this.toggleOpen({ id });
+ },
+ toggleSingle: () => {
+ this.toggleOpenSingle({ id });
+ },
+ };
+ this.dialogs[id] = dialog;
+ }
+ return dialog;
+ }
+
+ on(
+ eventType: DialogEvent['type'] | DialogsManagerEvent['type'],
+ { id, listener }: { listener: DialogEventHandler | DialogsManagerEventHandler; id?: DialogId },
+ ) {
+ if (dialogsManagerEvents.includes(eventType as DialogsManagerEvent['type'])) {
+ this.dialogsManagerEventListeners[eventType as DialogsManagerEvent['type']].push(
+ listener as DialogsManagerEventHandler,
+ );
+ return () => {
+ this.off(eventType, { listener });
+ };
+ }
+ if (!id) return noop;
+
+ if (!this.dialogEventListeners[id]) {
+ this.dialogEventListeners[id] = { close: [], open: [] };
+ }
+ this.dialogEventListeners[id][eventType] = [
+ ...(this.dialogEventListeners[id][eventType] ?? []),
+ listener as DialogEventHandler,
+ ];
+ return () => {
+ this.off(eventType, { id, listener });
+ };
+ }
+
+ off(
+ eventType: DialogEvent['type'] | DialogsManagerEvent['type'],
+ { id, listener }: { listener: DialogEventHandler | DialogsManagerEventHandler; id?: DialogId },
+ ) {
+ if (dialogsManagerEvents.includes(eventType as DialogsManagerEvent['type'])) {
+ const eventListeners = this.dialogsManagerEventListeners[
+ eventType as DialogsManagerEvent['type']
+ ];
+ eventListeners?.filter((l) => l !== listener);
+ return;
+ }
+
+ if (!id) return;
+
+ const eventListeners = this.dialogEventListeners[id]?.[eventType];
+ if (!eventListeners) return;
+ this.dialogEventListeners[id][eventType] = eventListeners.filter((l) => l !== listener);
+ }
+
+ open(params: GetOrCreateParams, single?: boolean) {
+ const dialog = this.getOrCreate(params);
+ if (dialog.isOpen) return;
+ if (single) {
+ this.closeAll();
+ }
+ this.dialogs[params.id].isOpen = true;
+ this.openDialogCount++;
+ this.dialogsManagerEventListeners.openCountChange.forEach((listener) => listener(this));
+ this.dialogEventListeners[params.id].open?.forEach((listener) => listener(dialog));
+ }
+
+ close(id: DialogId) {
+ const dialog = this.dialogs[id];
+ if (!dialog?.isOpen) return;
+ dialog.isOpen = false;
+ this.openDialogCount--;
+ this.dialogEventListeners[id].close?.forEach((listener) => listener(dialog));
+ this.dialogsManagerEventListeners.openCountChange.forEach((listener) => listener(this));
+ }
+
+ closeAll() {
+ Object.values(this.dialogs).forEach((dialog) => dialog.close());
+ }
+
+ toggleOpen(params: GetOrCreateParams) {
+ if (this.dialogs[params.id].isOpen) {
+ this.close(params.id);
+ } else {
+ this.open(params);
+ }
+ }
+
+ toggleOpenSingle(params: GetOrCreateParams) {
+ if (this.dialogs[params.id].isOpen) {
+ this.close(params.id);
+ } else {
+ this.open(params, true);
+ }
+ }
+
+ remove(id: DialogId) {
+ const dialogs = { ...this.dialogs };
+ if (!dialogs[id]) return;
+
+ const countListeners =
+ !!this.dialogEventListeners[id] &&
+ Object.values(this.dialogEventListeners[id]).reduce((acc, listeners) => {
+ acc += listeners.length;
+ return acc;
+ }, 0);
+
+ if (!countListeners) {
+ delete this.dialogEventListeners[id];
+ delete dialogs[id];
+ }
+ }
+}
diff --git a/src/components/Dialog/hooks/index.ts b/src/components/Dialog/hooks/index.ts
new file mode 100644
index 000000000..9d08c250c
--- /dev/null
+++ b/src/components/Dialog/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useDialog';
diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts
new file mode 100644
index 000000000..802fe5761
--- /dev/null
+++ b/src/components/Dialog/hooks/useDialog.ts
@@ -0,0 +1,32 @@
+import { useEffect, useState } from 'react';
+import { useDialogsManager } from '../../../context/DialogsManagerContext';
+import type { GetOrCreateParams } from '../DialogsManager';
+
+export const useDialog = ({ id, isOpen }: GetOrCreateParams) => {
+ const { dialogsManager } = useDialogsManager();
+
+ useEffect(
+ () => () => {
+ dialogsManager.remove(id);
+ },
+ [dialogsManager, id],
+ );
+
+ return dialogsManager.getOrCreate({ id, isOpen });
+};
+
+export const useDialogIsOpen = (id: string, source?: string) => {
+ const { dialogsManager } = useDialogsManager();
+ const [open, setOpen] = useState(false);
+
+ useEffect(() => {
+ const unsubscribeOpen = dialogsManager.on('open', { id, listener: () => setOpen(true) });
+ const unsubscribeClose = dialogsManager.on('close', { id, listener: () => setOpen(false) });
+ return () => {
+ unsubscribeOpen();
+ unsubscribeClose();
+ };
+ }, [dialogsManager, id, source]);
+
+ return open;
+};
diff --git a/src/components/Dialog/index.ts b/src/components/Dialog/index.ts
new file mode 100644
index 000000000..3bfd1c2dc
--- /dev/null
+++ b/src/components/Dialog/index.ts
@@ -0,0 +1,4 @@
+export * from './DialogAnchor';
+export * from './DialogsManager';
+export * from '../../context/DialogsManagerContext';
+export * from './hooks';
diff --git a/src/components/Message/hooks/useReactionHandler.ts b/src/components/Message/hooks/useReactionHandler.ts
index e057dceb6..b7275801d 100644
--- a/src/components/Message/hooks/useReactionHandler.ts
+++ b/src/components/Message/hooks/useReactionHandler.ts
@@ -174,8 +174,7 @@ export const useReactionClick = <
setShowDetailedReactions(false);
},
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [setShowDetailedReactions, reactionSelectorRef],
+ [closeReactionSelectorOnClick, setShowDetailedReactions, reactionSelectorRef],
);
useEffect(() => {
@@ -184,18 +183,12 @@ export const useReactionClick = <
if (showDetailedReactions && !hasListener.current) {
hasListener.current = true;
document.addEventListener('click', closeDetailedReactions);
-
- if (messageWrapper) {
- messageWrapper.addEventListener('mouseleave', closeDetailedReactions);
- }
+ messageWrapper?.addEventListener('mouseleave', closeDetailedReactions);
}
if (!showDetailedReactions && hasListener.current) {
document.removeEventListener('click', closeDetailedReactions);
-
- if (messageWrapper) {
- messageWrapper.removeEventListener('mouseleave', closeDetailedReactions);
- }
+ messageWrapper?.removeEventListener('mouseleave', closeDetailedReactions);
hasListener.current = false;
}
diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx
index 194313dde..6c93c64d0 100644
--- a/src/components/MessageActions/MessageActions.tsx
+++ b/src/components/MessageActions/MessageActions.tsx
@@ -1,23 +1,16 @@
-import React, {
- ElementRef,
- PropsWithChildren,
- useCallback,
- useEffect,
- useRef,
- useState,
-} from 'react';
+import clsx from 'clsx';
+import React, { ElementRef, PropsWithChildren, useCallback, useEffect, useRef } from 'react';
import { MessageActionsBox } from './MessageActionsBox';
+import { DialogAnchor, useDialog, useDialogIsOpen } from '../Dialog';
import { ActionsIcon as DefaultActionsIcon } from '../Message/icons';
import { isUserMuted } from '../Message/utils';
-
import { useChatContext } from '../../context/ChatContext';
import { MessageContextValue, useMessageContext } from '../../context/MessageContext';
+import { useTranslationContext } from '../../context';
import type { DefaultStreamChatGenerics, IconProps } from '../../types/types';
-import { useMessageActionsBoxPopper } from './hooks';
-import { useTranslationContext } from '../../context';
type MessageContextPropsToPick =
| 'getMessageActions'
@@ -88,16 +81,21 @@ export const MessageActions = <
const message = propMessage || contextMessage;
const isMine = mine ? mine() : isMyMessage();
- const [actionsBoxOpen, setActionsBoxOpen] = useState(false);
-
const isMuted = useCallback(() => isUserMuted(message, mutes), [message, mutes]);
- const hideOptions = useCallback((event: MouseEvent | KeyboardEvent) => {
- if (event instanceof KeyboardEvent && event.key !== 'Escape') {
- return;
- }
- setActionsBoxOpen(false);
- }, []);
+ const dialogId = `message-actions--${message.id}`;
+ const dialog = useDialog({ id: dialogId });
+ const dialogIsOpen = useDialogIsOpen(dialogId);
+
+ const hideOptions = useCallback(
+ (event: MouseEvent | KeyboardEvent) => {
+ if (event instanceof KeyboardEvent && event.key !== 'Escape') {
+ return;
+ }
+ dialog?.close();
+ },
+ [dialog],
+ );
const messageActions = getMessageActions();
const messageDeletedAt = !!message?.deleted_at;
@@ -114,50 +112,46 @@ export const MessageActions = <
}, [hideOptions, messageDeletedAt]);
useEffect(() => {
- if (!actionsBoxOpen) return;
+ if (!dialogIsOpen) return;
- document.addEventListener('click', hideOptions);
document.addEventListener('keyup', hideOptions);
return () => {
- document.removeEventListener('click', hideOptions);
document.removeEventListener('keyup', hideOptions);
};
- }, [actionsBoxOpen, hideOptions]);
+ }, [dialog, dialogIsOpen, hideOptions]);
const actionsBoxButtonRef = useRef>(null);
- const { attributes, popperElementRef, styles } = useMessageActionsBoxPopper({
- open: actionsBoxOpen,
- placement: isMine ? 'top-end' : 'top-start',
- referenceElement: actionsBoxButtonRef.current,
- });
-
if (!messageActions.length && !customMessageActions) return null;
return (
-
+
+
+
>;
customWrapperClass?: string;
inline?: boolean;
+ toggleOpen?: () => void;
};
const MessageActionsWrapper = (props: PropsWithChildren) => {
- const { children, customWrapperClass, inline, setActionsBoxOpen } = props;
+ const { children, customWrapperClass, inline, toggleOpen } = props;
const defaultWrapperClass = `
str-chat__message-simple__actions__action
str-chat__message-simple__actions__action--options
str-chat__message-actions-container`;
- const wrapperClass = customWrapperClass || defaultWrapperClass;
-
- const onClickOptionsAction = (event: React.BaseSyntheticEvent) => {
- event.stopPropagation();
- setActionsBoxOpen((prev) => !prev);
- };
-
const wrapperProps = {
- className: wrapperClass,
+ className: customWrapperClass || defaultWrapperClass,
'data-testid': 'message-actions',
- onClick: onClickOptionsAction,
+ onClick: toggleOpen,
};
if (inline) return {children} ;
diff --git a/src/components/MessageActions/MessageActionsBox.tsx b/src/components/MessageActions/MessageActionsBox.tsx
index a19b252ad..b7e618317 100644
--- a/src/components/MessageActions/MessageActionsBox.tsx
+++ b/src/components/MessageActions/MessageActionsBox.tsx
@@ -1,5 +1,4 @@
-import React, { ComponentProps } from 'react';
-import clsx from 'clsx';
+import React from 'react';
import { MESSAGE_ACTIONS } from '../Message/utils';
@@ -28,143 +27,127 @@ export type MessageActionsBoxProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
> = Pick, PropsDrilledToMessageActionsBox> & {
isUserMuted: () => boolean;
- mine: boolean;
- open: boolean;
-} & ComponentProps<'div'>;
-
-const UnMemoizedMessageActionsBox = React.forwardRef(
- (
- props: MessageActionsBoxProps,
- ref: React.ForwardedRef,
- ) => {
- const {
- getMessageActions,
- handleDelete,
- handleEdit,
- handleFlag,
- handleMarkUnread,
- handleMute,
- handlePin,
- isUserMuted,
- mine,
- open = false,
- ...restDivProps
- } = props;
-
- const {
- CustomMessageActionsList = DefaultCustomMessageActionsList,
- } = useComponentContext('MessageActionsBox');
- const { setQuotedMessage } = useChannelActionContext('MessageActionsBox');
- const { customMessageActions, message, threadList } = useMessageContext(
- 'MessageActionsBox',
- );
-
- const { t } = useTranslationContext('MessageActionsBox');
-
- const messageActions = getMessageActions();
-
- const handleQuote = () => {
- setQuotedMessage(message);
-
- const elements = message.parent_id
- ? document.querySelectorAll('.str-chat__thread .str-chat__textarea__textarea')
- : document.getElementsByClassName('str-chat__textarea__textarea');
- const textarea = elements.item(0);
-
- if (textarea instanceof HTMLTextAreaElement) {
- textarea.focus();
- }
- };
-
- const rootClassName = clsx('str-chat__message-actions-box', {
- 'str-chat__message-actions-box--open': open,
- });
- const buttonClassName =
- 'str-chat__message-actions-list-item str-chat__message-actions-list-item-button';
-
- return (
-
-
(
+ props: MessageActionsBoxProps
,
+) => {
+ const {
+ getMessageActions,
+ handleDelete,
+ handleEdit,
+ handleFlag,
+ handleMarkUnread,
+ handleMute,
+ handlePin,
+ isUserMuted,
+ } = props;
+
+ const {
+ CustomMessageActionsList = DefaultCustomMessageActionsList,
+ } = useComponentContext('MessageActionsBox');
+ const { setQuotedMessage } = useChannelActionContext('MessageActionsBox');
+ const { customMessageActions, message, threadList } = useMessageContext(
+ 'MessageActionsBox',
+ );
+
+ const { t } = useTranslationContext('MessageActionsBox');
+
+ const messageActions = getMessageActions();
+
+ const handleQuote = () => {
+ setQuotedMessage(message);
+
+ const elements = message.parent_id
+ ? document.querySelectorAll('.str-chat__thread .str-chat__textarea__textarea')
+ : document.getElementsByClassName('str-chat__textarea__textarea');
+ const textarea = elements.item(0);
+
+ if (textarea instanceof HTMLTextAreaElement) {
+ textarea.focus();
+ }
+ };
+
+ const buttonClassName =
+ 'str-chat__message-actions-list-item str-chat__message-actions-list-item-button';
+
+ return (
+
+
+ {messageActions.indexOf(MESSAGE_ACTIONS.quote) > -1 && (
+
+ {t('Reply')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.pin) > -1 && !message.parent_id && (
+
+ {!message.pinned ? t('Pin') : t('Unpin')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.markUnread) > -1 && !threadList && !!message.id && (
+
+ {t('Mark as unread')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.flag) > -1 && (
+
+ {t('Flag')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.mute) > -1 && (
+
+ {isUserMuted() ? t('Unmute') : t('Mute')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.edit) > -1 && (
+
+ {t('Edit Message')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.delete) > -1 && (
+
-
- {messageActions.indexOf(MESSAGE_ACTIONS.quote) > -1 && (
-
- {t('Reply')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.pin) > -1 && !message.parent_id && (
-
- {!message.pinned ? t('Pin') : t('Unpin')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.markUnread) > -1 && !threadList && !!message.id && (
-
- {t('Mark as unread')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.flag) > -1 && (
-
- {t('Flag')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.mute) > -1 && (
-
- {isUserMuted() ? t('Unmute') : t('Mute')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.edit) > -1 && (
-
- {t('Edit Message')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.delete) > -1 && (
-
- {t('Delete')}
-
- )}
-
-
- );
- },
-);
+ {t
('Delete')}
+
+ )}
+
+ );
+};
/**
* A popup box that displays the available actions on a message, such as edit, delete, pin, etc.
diff --git a/src/components/index.ts b/src/components/index.ts
index 9b1a388cb..d4a6e8f08 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -11,6 +11,7 @@ export * from './ChatAutoComplete';
export * from './ChatDown';
export * from './CommandItem';
export * from './DateSeparator';
+export * from './Dialog';
export * from './EmoticonItem';
export * from './EmptyStateIndicator';
export * from './EventComponent';
diff --git a/src/context/DialogsManagerContext.tsx b/src/context/DialogsManagerContext.tsx
new file mode 100644
index 000000000..3aa2ef648
--- /dev/null
+++ b/src/context/DialogsManagerContext.tsx
@@ -0,0 +1,28 @@
+import React, { useContext, useState } from 'react';
+import { PropsWithChildrenOnly } from '../types/types';
+import { DialogsManager } from '../components/Dialog/DialogsManager';
+import { DialogPortalDestination } from '../components/Dialog/DialogPortal';
+
+type DialogsManagerProviderContextValue = {
+ dialogsManager: DialogsManager;
+};
+
+const DialogsManagerProviderContext = React.createContext<
+ DialogsManagerProviderContextValue | undefined
+>(undefined);
+
+export const DialogsManagerProvider = ({ children }: PropsWithChildrenOnly) => {
+ const [dialogsManager] = useState(() => new DialogsManager());
+
+ return (
+
+ {children}
+
+
+ );
+};
+
+export const useDialogsManager = () => {
+ const value = useContext(DialogsManagerProviderContext);
+ return value as DialogsManagerProviderContextValue;
+};
diff --git a/src/context/index.ts b/src/context/index.ts
index 21c075feb..1f4f3fa85 100644
--- a/src/context/index.ts
+++ b/src/context/index.ts
@@ -3,6 +3,7 @@ export * from './ChannelListContext';
export * from './ChannelStateContext';
export * from './ChatContext';
export * from './ComponentContext';
+export * from './DialogsManagerContext';
export * from './MessageContext';
export * from './MessageBounceContext';
export * from './MessageInputContext';
From 143555bbd8ddca1812d77c5a870ec33c6f65a625 Mon Sep 17 00:00:00 2001
From: martincupela
Date: Thu, 5 Sep 2024 12:38:38 +0200
Subject: [PATCH 02/29] feat: use own anchor root for DialogAnchor
---
src/components/Dialog/DialogAnchor.tsx | 8 +-
src/components/Dialog/DialogPortal.tsx | 27 ++-
.../MessageActions/MessageActions.tsx | 5 +-
.../MessageActions/MessageActionsBox.tsx | 163 ++++++++++--------
4 files changed, 109 insertions(+), 94 deletions(-)
diff --git a/src/components/Dialog/DialogAnchor.tsx b/src/components/Dialog/DialogAnchor.tsx
index 31e62dcec..20c7434f0 100644
--- a/src/components/Dialog/DialogAnchor.tsx
+++ b/src/components/Dialog/DialogAnchor.tsx
@@ -1,3 +1,4 @@
+import clsx from 'clsx';
import { Placement } from '@popperjs/core';
import React, { ComponentProps, PropsWithChildren, useEffect, useRef } from 'react';
import { usePopper } from 'react-popper';
@@ -48,7 +49,7 @@ export function useDialogAnchor({
type DialogAnchorProps = PropsWithChildren> & {
id: string;
-} & ComponentProps<'div' | 'span'>;
+} & ComponentProps<'div'>;
export const DialogAnchor = ({
children,
@@ -56,6 +57,7 @@ export const DialogAnchor = ({
id,
placement = 'auto',
referenceElement = null,
+ ...restDivProps
}: DialogAnchorProps) => {
const open = useDialogIsOpen(id);
const { attributes, popperElementRef, styles } = useDialogAnchor({
@@ -67,8 +69,10 @@ export const DialogAnchor = ({
return (
diff --git a/src/components/Dialog/DialogPortal.tsx b/src/components/Dialog/DialogPortal.tsx
index 9110b42e5..e31a3582f 100644
--- a/src/components/Dialog/DialogPortal.tsx
+++ b/src/components/Dialog/DialogPortal.tsx
@@ -18,22 +18,17 @@ export const DialogPortalDestination = () => {
);
return (
- <>
-
dialogsManager.closeAll()}
- style={{
- height: '100%',
- inset: '0',
- overflow: 'hidden',
- position: 'absolute',
- width: '100%',
- zIndex: shouldRender ? '2' : '-1',
- }}
- >
-
-
- >
+
dialogsManager.closeAll()}
+ style={
+ {
+ '--str-chat__dialog-overlay-height': shouldRender ? '100%' : '0',
+ } as React.CSSProperties
+ }
+ >
);
};
diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx
index 9860f9b7d..41dd364e7 100644
--- a/src/components/MessageActions/MessageActions.tsx
+++ b/src/components/MessageActions/MessageActions.tsx
@@ -146,9 +146,6 @@ export const MessageActions = <
toggleOpen={dialog?.toggleSingle}
>
= Pick, PropsDrilledToMessageActionsBox> & {
isUserMuted: () => boolean;
-};
+ mine: boolean;
+ open: boolean;
+} & ComponentProps<'div'>;
const UnMemoizedMessageActionsBox = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
@@ -35,6 +38,7 @@ const UnMemoizedMessageActionsBox = <
props: MessageActionsBoxProps,
) => {
const {
+ className,
getMessageActions,
handleDelete,
handleEdit,
@@ -43,6 +47,8 @@ const UnMemoizedMessageActionsBox = <
handleMute,
handlePin,
isUserMuted,
+ open,
+ ...restDivProps
} = props;
const {
@@ -70,81 +76,92 @@ const UnMemoizedMessageActionsBox = <
}
};
+ const rootClassName = clsx('str-chat__message-actions-box', className, {
+ 'str-chat__message-actions-box--open': open,
+ });
+
const buttonClassName =
'str-chat__message-actions-list-item str-chat__message-actions-list-item-button';
return (
-
-
- {messageActions.indexOf(MESSAGE_ACTIONS.quote) > -1 && (
-
- {t('Reply')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.pin) > -1 && !message.parent_id && (
-
- {!message.pinned ? t('Pin') : t('Unpin')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.markUnread) > -1 && !threadList && !!message.id && (
-
- {t('Mark as unread')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.flag) > -1 && (
-
- {t('Flag')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.mute) > -1 && (
-
- {isUserMuted() ? t('Unmute') : t('Mute')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.edit) > -1 && (
-
- {t('Edit Message')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.delete) > -1 && (
-
- {t('Delete')}
-
- )}
+
+
+
+ {messageActions.indexOf(MESSAGE_ACTIONS.quote) > -1 && (
+
+ {t('Reply')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.pin) > -1 && !message.parent_id && (
+
+ {!message.pinned ? t('Pin') : t('Unpin')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.markUnread) > -1 && !threadList && !!message.id && (
+
+ {t('Mark as unread')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.flag) > -1 && (
+
+ {t('Flag')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.mute) > -1 && (
+
+ {isUserMuted() ? t('Unmute') : t('Mute')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.edit) > -1 && (
+
+ {t('Edit Message')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.delete) > -1 && (
+
+ {t('Delete')}
+
+ )}
+
);
};
From c3ed3175dfcff4a73f1f9c4bcb60e5a288dac2aa Mon Sep 17 00:00:00 2001
From: martincupela
Date: Thu, 5 Sep 2024 13:10:56 +0200
Subject: [PATCH 03/29] feat: forward custom DialogsManager id via
DialogsManagerProvider
---
src/context/DialogsManagerContext.tsx | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/src/context/DialogsManagerContext.tsx b/src/context/DialogsManagerContext.tsx
index 3aa2ef648..1e564e7ee 100644
--- a/src/context/DialogsManagerContext.tsx
+++ b/src/context/DialogsManagerContext.tsx
@@ -1,5 +1,4 @@
-import React, { useContext, useState } from 'react';
-import { PropsWithChildrenOnly } from '../types/types';
+import React, { PropsWithChildren, useContext, useState } from 'react';
import { DialogsManager } from '../components/Dialog/DialogsManager';
import { DialogPortalDestination } from '../components/Dialog/DialogPortal';
@@ -11,8 +10,8 @@ const DialogsManagerProviderContext = React.createContext<
DialogsManagerProviderContextValue | undefined
>(undefined);
-export const DialogsManagerProvider = ({ children }: PropsWithChildrenOnly) => {
- const [dialogsManager] = useState(() => new DialogsManager());
+export const DialogsManagerProvider = ({ children, id }: PropsWithChildren<{ id?: string }>) => {
+ const [dialogsManager] = useState(() => new DialogsManager({ id }));
return (
From 71c768558627731eb9a4c143a62f5446c4922789 Mon Sep 17 00:00:00 2001
From: martincupela
Date: Thu, 5 Sep 2024 15:03:17 +0200
Subject: [PATCH 04/29] feat: apply dialogs manager to message lists only
---
src/components/Channel/Channel.tsx | 31 ++--
src/components/MessageList/MessageList.tsx | 83 ++++++-----
.../MessageList/VirtualizedMessageList.tsx | 139 +++++++++---------
3 files changed, 128 insertions(+), 125 deletions(-)
diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx
index f33613504..754028309 100644
--- a/src/components/Channel/Channel.tsx
+++ b/src/components/Channel/Channel.tsx
@@ -77,7 +77,6 @@ import { hasMoreMessagesProbably, UnreadMessagesSeparator } from '../MessageList
import { useChannelContainerClasses } from './hooks/useChannelContainerClasses';
import { findInMsgSetByDate, findInMsgSetById, makeAddNotifications } from './utils';
import { DateSeparator } from '../DateSeparator';
-import { DialogsManagerProvider } from '../Dialog';
import { EventComponent } from '../EventComponent';
import { defaultReactionOptions, ReactionOptions } from '../Reactions';
import { getChannel } from '../../utils';
@@ -1330,22 +1329,20 @@ const ChannelInner = <
return (
-
-
-
-
-
-
- {dragAndDropWindow && (
- {children}
- )}
- {!dragAndDropWindow && <>{children}>}
-
-
-
-
-
-
+
+
+
+
+
+ {dragAndDropWindow && (
+ {children}
+ )}
+ {!dragAndDropWindow && <>{children}>}
+
+
+
+
+
);
};
diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx
index 30f1dea12..785982d27 100644
--- a/src/components/MessageList/MessageList.tsx
+++ b/src/components/MessageList/MessageList.tsx
@@ -20,6 +20,7 @@ import {
ChannelStateContextValue,
useChannelStateContext,
} from '../../context/ChannelStateContext';
+import { DialogsManagerProvider } from '../../context';
import { useChatContext } from '../../context/ChatContext';
import { useComponentContext } from '../../context/ComponentContext';
import { MessageListContextProvider } from '../../context/MessageListContext';
@@ -225,47 +226,49 @@ const MessageListWithContext = <
return (
- {!threadList && showUnreadMessagesNotification && (
-
- )}
-
- {showEmptyStateIndicator ? (
-
- ) : (
-
- {props.loadingMore && }
-
- }
- loadNextPage={loadMoreNewer}
- loadPreviousPage={loadMore}
- threshold={loadMoreScrollThreshold}
- {...restInternalInfiniteScrollProps}
- >
-
-
-
-
-
+
+ {!threadList && showUnreadMessagesNotification && (
+
)}
-
+
+ {showEmptyStateIndicator ? (
+
+ ) : (
+
+ {props.loadingMore && }
+
+ }
+ loadNextPage={loadMoreNewer}
+ loadPreviousPage={loadMore}
+ threshold={loadMoreScrollThreshold}
+ {...restInternalInfiniteScrollProps}
+ >
+
+
+
+
+
+ )}
+
+
- {!threadList && showUnreadMessagesNotification && (
-
- )}
-
- >
- atBottomStateChange={atBottomStateChange}
- atBottomThreshold={100}
- atTopStateChange={atTopStateChange}
- atTopThreshold={100}
- className='str-chat__message-list-scroll'
- components={{
- EmptyPlaceholder,
- Footer,
- Header,
- Item,
- ...virtuosoComponentsFromProps,
- }}
- computeItemKey={computeItemKey}
- context={{
- additionalMessageInputProps,
- closeReactionSelectorOnClick,
- customClasses,
- customMessageActions,
- customMessageRenderer,
- DateSeparator,
- firstUnreadMessageId: channelUnreadUiState?.first_unread_message_id,
- formatDate,
- head,
- lastReadDate: channelUnreadUiState?.last_read,
- lastReadMessageId: channelUnreadUiState?.last_read_message_id,
- lastReceivedMessageId,
- loadingMore,
- Message: MessageUIComponent,
- messageActions,
- messageGroupStyles,
- MessageSystem,
- numItemsPrepended,
- ownMessagesReadByOthers,
- processedMessages,
- reactionDetailsSort,
- shouldGroupByUser,
- sortReactionDetails,
- sortReactions,
- threadList,
- unreadMessageCount: channelUnreadUiState?.unread_messages,
- UnreadMessagesSeparator,
- virtuosoRef: virtuoso,
- }}
- firstItemIndex={calculateFirstItemIndex(numItemsPrepended)}
- followOutput={followOutput}
- increaseViewportBy={{ bottom: 200, top: 0 }}
- initialTopMostItemIndex={calculateInitialTopMostItemIndex(
- processedMessages,
- highlightedMessageId,
- )}
- itemContent={messageRenderer}
- itemSize={fractionalItemSize}
- itemsRendered={handleItemsRendered}
- key={messageSetKey}
- overscan={overscan}
- ref={virtuoso}
- style={{ overflowX: 'hidden' }}
- totalCount={processedMessages.length}
- {...overridingVirtuosoProps}
- {...(scrollSeekPlaceHolder ? { scrollSeek: scrollSeekPlaceHolder } : {})}
- {...(defaultItemHeight ? { defaultItemHeight } : {})}
- />
-
+
+ {!threadList && showUnreadMessagesNotification && (
+
+ )}
+
+ >
+ atBottomStateChange={atBottomStateChange}
+ atBottomThreshold={100}
+ atTopStateChange={atTopStateChange}
+ atTopThreshold={100}
+ className='str-chat__message-list-scroll'
+ components={{
+ EmptyPlaceholder,
+ Footer,
+ Header,
+ Item,
+ ...virtuosoComponentsFromProps,
+ }}
+ computeItemKey={computeItemKey}
+ context={{
+ additionalMessageInputProps,
+ closeReactionSelectorOnClick,
+ customClasses,
+ customMessageActions,
+ customMessageRenderer,
+ DateSeparator,
+ firstUnreadMessageId: channelUnreadUiState?.first_unread_message_id,
+ formatDate,
+ head,
+ lastReadDate: channelUnreadUiState?.last_read,
+ lastReadMessageId: channelUnreadUiState?.last_read_message_id,
+ lastReceivedMessageId,
+ loadingMore,
+ Message: MessageUIComponent,
+ messageActions,
+ messageGroupStyles,
+ MessageSystem,
+ numItemsPrepended,
+ ownMessagesReadByOthers,
+ processedMessages,
+ reactionDetailsSort,
+ shouldGroupByUser,
+ sortReactionDetails,
+ sortReactions,
+ threadList,
+ unreadMessageCount: channelUnreadUiState?.unread_messages,
+ UnreadMessagesSeparator,
+ virtuosoRef: virtuoso,
+ }}
+ firstItemIndex={calculateFirstItemIndex(numItemsPrepended)}
+ followOutput={followOutput}
+ increaseViewportBy={{ bottom: 200, top: 0 }}
+ initialTopMostItemIndex={calculateInitialTopMostItemIndex(
+ processedMessages,
+ highlightedMessageId,
+ )}
+ itemContent={messageRenderer}
+ itemSize={fractionalItemSize}
+ itemsRendered={handleItemsRendered}
+ key={messageSetKey}
+ overscan={overscan}
+ ref={virtuoso}
+ style={{ overflowX: 'hidden' }}
+ totalCount={processedMessages.length}
+ {...overridingVirtuosoProps}
+ {...(scrollSeekPlaceHolder ? { scrollSeek: scrollSeekPlaceHolder } : {})}
+ {...(defaultItemHeight ? { defaultItemHeight } : {})}
+ />
+
+
Date: Thu, 5 Sep 2024 15:03:52 +0200
Subject: [PATCH 05/29] fix: do not forward prop mine to MessageActionsBox root
div
---
src/components/MessageActions/MessageActionsBox.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/components/MessageActions/MessageActionsBox.tsx b/src/components/MessageActions/MessageActionsBox.tsx
index fdbee888d..e7deb75ec 100644
--- a/src/components/MessageActions/MessageActionsBox.tsx
+++ b/src/components/MessageActions/MessageActionsBox.tsx
@@ -47,6 +47,7 @@ const UnMemoizedMessageActionsBox = <
handleMute,
handlePin,
isUserMuted,
+ mine,
open,
...restDivProps
} = props;
From 223ddeae0ed6e6bc67ce1cdaccc17d687bb61285 Mon Sep 17 00:00:00 2001
From: martincupela
Date: Thu, 5 Sep 2024 15:06:11 +0200
Subject: [PATCH 06/29] test: fix MessageActions tests
---
.../__tests__/MessageActions.test.js | 278 +++++++++++-------
.../__tests__/MessageActionsBox.test.js | 128 ++++----
2 files changed, 250 insertions(+), 156 deletions(-)
diff --git a/src/components/MessageActions/__tests__/MessageActions.test.js b/src/components/MessageActions/__tests__/MessageActions.test.js
index 9c7afa2d3..cbb350f20 100644
--- a/src/components/MessageActions/__tests__/MessageActions.test.js
+++ b/src/components/MessageActions/__tests__/MessageActions.test.js
@@ -1,15 +1,19 @@
import React from 'react';
import '@testing-library/jest-dom';
import testRenderer from 'react-test-renderer';
-import { cleanup, fireEvent, render } from '@testing-library/react';
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { MessageActions } from '../MessageActions';
import { MessageActionsBox as MessageActionsBoxMock } from '../MessageActionsBox';
-import { ChannelStateProvider } from '../../../context/ChannelStateContext';
-import { ChatProvider } from '../../../context/ChatContext';
-import { MessageProvider } from '../../../context/MessageContext';
-import { TranslationProvider } from '../../../context/TranslationContext';
+import {
+ ChannelStateProvider,
+ ChatProvider,
+ ComponentProvider,
+ DialogsManagerProvider,
+ MessageProvider,
+ TranslationProvider,
+} from '../../../context';
import { generateMessage, getTestClient, mockTranslationContext } from '../../../mock-builders';
@@ -45,17 +49,22 @@ const chatClient = getTestClient();
function renderMessageActions(customProps, renderer = render) {
return renderer(
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
,
);
}
+const dialogOverlayTestId = 'str-chat__dialog-overlay';
const messageActionsTestId = 'message-actions';
describe(' component', () => {
afterEach(cleanup);
@@ -64,32 +73,44 @@ describe(' component', () => {
it('should render correctly', () => {
const tree = renderMessageActions({}, testRenderer.create);
expect(tree.toJSON()).toMatchInlineSnapshot(`
-
+
+
+
+
+ ,
+
,
+ ]
`);
});
@@ -101,77 +122,98 @@ describe(' component', () => {
expect(queryByTestId(messageActionsTestId)).toBeNull();
});
- it('should open message actions box on click', () => {
+ it('should open message actions box on click', async () => {
const { getByTestId } = renderMessageActions();
expect(MessageActionsBoxMock).toHaveBeenCalledWith(
expect.objectContaining({ open: false }),
{},
);
- fireEvent.click(getByTestId(messageActionsTestId));
+ const dialogOverlay = screen.getByTestId(dialogOverlayTestId);
+ expect(dialogOverlay.children).toHaveLength(1);
+ await act(async () => {
+ await fireEvent.click(getByTestId(messageActionsTestId));
+ });
expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
expect.objectContaining({ open: true }),
{},
);
+ expect(dialogOverlay.children).toHaveLength(1);
});
- it('should close message actions box on icon click if already opened', () => {
+ it('should close message actions box on icon click if already opened', async () => {
const { getByTestId } = renderMessageActions();
expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
expect.objectContaining({ open: false }),
{},
);
- fireEvent.click(getByTestId(messageActionsTestId));
+ await act(async () => {
+ await fireEvent.click(getByTestId(messageActionsTestId));
+ });
expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
expect.objectContaining({ open: true }),
{},
);
- fireEvent.click(getByTestId(messageActionsTestId));
+ await act(async () => {
+ await fireEvent.click(getByTestId(messageActionsTestId));
+ });
expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
expect.objectContaining({ open: false }),
{},
);
});
- it('should close message actions box when user clicks anywhere in the document if it is already opened', () => {
+ it('should close message actions box when user clicks overlay if it is already opened', async () => {
const { getByRole } = renderMessageActions();
- fireEvent.click(getByRole('button'));
-
+ await act(async () => {
+ await fireEvent.click(getByRole('button'));
+ });
expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
expect.objectContaining({ open: true }),
{},
);
- fireEvent.click(document);
+ const dialogOverlay = screen.getByTestId(dialogOverlayTestId);
+ await act(async () => {
+ await fireEvent.click(dialogOverlay);
+ });
expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
expect.objectContaining({ open: false }),
{},
);
});
- it('should close message actions box when user presses Escape key', () => {
+ it('should close message actions box when user presses Escape key', async () => {
const { getByRole } = renderMessageActions();
- fireEvent.click(getByRole('button'));
+ await act(async () => {
+ await fireEvent.click(getByRole('button'));
+ });
expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
expect.objectContaining({ open: true }),
{},
);
- fireEvent.keyUp(document, { charCode: 27, code: 'Escape', key: 'Escape' });
+ await act(async () => {
+ await fireEvent.keyUp(document, { charCode: 27, code: 'Escape', key: 'Escape' });
+ });
expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
expect.objectContaining({ open: false }),
{},
);
});
- it('should close actions box open on mouseleave if container ref provided', () => {
+ it('should close actions box open on mouseleave if container ref provided', async () => {
const customProps = {
messageWrapperRef: { current: wrapperMock },
};
const { getByRole } = renderMessageActions(customProps);
- fireEvent.click(getByRole('button'));
+ await act(async () => {
+ await fireEvent.click(getByRole('button'));
+ });
expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
expect.objectContaining({ open: true }),
{},
);
- fireEvent.mouseLeave(customProps.messageWrapperRef.current);
+ await act(async () => {
+ await fireEvent.mouseLeave(customProps.messageWrapperRef.current);
+ });
expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
expect.objectContaining({ open: false }),
{},
@@ -196,12 +238,13 @@ describe(' component', () => {
);
});
- it('should not register click and keyup event listeners to close actions box until opened', () => {
+ it('should not register click and keyup event listeners to close actions box until opened', async () => {
const { getByRole } = renderMessageActions();
const addEventListener = jest.spyOn(document, 'addEventListener');
expect(document.addEventListener).not.toHaveBeenCalled();
- fireEvent.click(getByRole('button'));
- expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function));
+ await act(async () => {
+ await fireEvent.click(getByRole('button'));
+ });
expect(document.addEventListener).toHaveBeenCalledWith('keyup', expect.any(Function));
addEventListener.mockClear();
});
@@ -216,13 +259,14 @@ describe(' component', () => {
removeEventListener.mockClear();
});
- it('should remove event listener when unmounted', () => {
+ it('should remove event listener when unmounted', async () => {
const { getByRole, unmount } = renderMessageActions();
const removeEventListener = jest.spyOn(document, 'removeEventListener');
- fireEvent.click(getByRole('button'));
+ await act(async () => {
+ await fireEvent.click(getByRole('button'));
+ });
expect(document.removeEventListener).not.toHaveBeenCalled();
unmount();
- expect(document.removeEventListener).toHaveBeenCalledWith('click', expect.any(Function));
expect(document.removeEventListener).toHaveBeenCalledWith('keyup', expect.any(Function));
removeEventListener.mockClear();
});
@@ -235,32 +279,44 @@ describe(' component', () => {
testRenderer.create,
);
expect(tree.toJSON()).toMatchInlineSnapshot(`
-
+
+
+
+
+ ,
+
,
+ ]
`);
});
@@ -272,32 +328,44 @@ describe(' component', () => {
testRenderer.create,
);
expect(tree.toJSON()).toMatchInlineSnapshot(`
-
-
-
-
-
-
-
-
+
+
+
+
+ ,
+
,
+ ]
`);
});
});
diff --git a/src/components/MessageActions/__tests__/MessageActionsBox.test.js b/src/components/MessageActions/__tests__/MessageActionsBox.test.js
index 2f786facf..4975bd3e4 100644
--- a/src/components/MessageActions/__tests__/MessageActionsBox.test.js
+++ b/src/components/MessageActions/__tests__/MessageActionsBox.test.js
@@ -18,7 +18,7 @@ import {
import { Message } from '../../Message';
import { Channel } from '../../Channel';
import { Chat } from '../../Chat';
-import { ChatProvider } from '../../../context';
+import { ChatProvider, ComponentProvider, DialogsManagerProvider } from '../../../context';
expect.extend(toHaveNoViolations);
@@ -34,19 +34,27 @@ async function renderComponent(boxProps, messageContext = {}) {
return render(
key }}>
-
-
+
-
-
-
+
+
+
+
+
+
+
,
);
@@ -72,7 +80,9 @@ describe('MessageActionsBox', () => {
getMessageActionsMock.mockImplementationOnce(() => ['flag']);
const handleFlag = jest.fn();
const { container, getByText } = await renderComponent({ handleFlag });
- fireEvent.click(getByText('Flag'));
+ await act(async () => {
+ await fireEvent.click(getByText('Flag'));
+ });
expect(handleFlag).toHaveBeenCalledTimes(1);
const results = await axe(container);
expect(results).toHaveNoViolations();
@@ -85,7 +95,9 @@ describe('MessageActionsBox', () => {
handleMute,
isUserMuted: () => false,
});
- fireEvent.click(getByText('Mute'));
+ await act(async () => {
+ await fireEvent.click(getByText('Mute'));
+ });
expect(handleMute).toHaveBeenCalledTimes(1);
const results = await axe(container);
expect(results).toHaveNoViolations();
@@ -98,7 +110,9 @@ describe('MessageActionsBox', () => {
handleMute,
isUserMuted: () => true,
});
- fireEvent.click(getByText('Unmute'));
+ await act(async () => {
+ await fireEvent.click(getByText('Unmute'));
+ });
expect(handleMute).toHaveBeenCalledTimes(1);
const results = await axe(container);
expect(results).toHaveNoViolations();
@@ -108,7 +122,9 @@ describe('MessageActionsBox', () => {
getMessageActionsMock.mockImplementationOnce(() => ['edit']);
const handleEdit = jest.fn();
const { container, getByText } = await renderComponent({ handleEdit });
- fireEvent.click(getByText('Edit Message'));
+ await act(async () => {
+ await fireEvent.click(getByText('Edit Message'));
+ });
expect(handleEdit).toHaveBeenCalledTimes(1);
const results = await axe(container);
expect(results).toHaveNoViolations();
@@ -118,7 +134,9 @@ describe('MessageActionsBox', () => {
getMessageActionsMock.mockImplementationOnce(() => ['delete']);
const handleDelete = jest.fn();
const { container, getByText } = await renderComponent({ handleDelete });
- fireEvent.click(getByText('Delete'));
+ await act(async () => {
+ await fireEvent.click(getByText('Delete'));
+ });
expect(handleDelete).toHaveBeenCalledTimes(1);
const results = await axe(container);
expect(results).toHaveNoViolations();
@@ -129,7 +147,9 @@ describe('MessageActionsBox', () => {
const handlePin = jest.fn();
const message = generateMessage({ pinned: false });
const { container, getByText } = await renderComponent({ handlePin, message });
- fireEvent.click(getByText('Pin'));
+ await act(async () => {
+ await fireEvent.click(getByText('Pin'));
+ });
expect(handlePin).toHaveBeenCalledTimes(1);
const results = await axe(container);
expect(results).toHaveNoViolations();
@@ -140,7 +160,9 @@ describe('MessageActionsBox', () => {
const handlePin = jest.fn();
const message = generateMessage({ pinned: true });
const { container, getByText } = await renderComponent({ handlePin, message });
- fireEvent.click(getByText('Unpin'));
+ await act(async () => {
+ await fireEvent.click(getByText('Unpin'));
+ });
expect(handlePin).toHaveBeenCalledTimes(1);
const results = await axe(container);
expect(results).toHaveNoViolations();
@@ -195,16 +217,18 @@ describe('MessageActionsBox', () => {
'upload-file',
];
const renderMarkUnreadUI = async ({ channelProps, chatProps, messageProps }) =>
- await act(() => {
- render(
+ await act(async () => {
+ await render(
-
+
+
+
,
);
@@ -230,8 +254,8 @@ describe('MessageActionsBox', () => {
chatProps: { client },
messageProps: { message },
});
- await act(() => {
- fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
+ await act(async () => {
+ await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
});
expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument();
});
@@ -257,8 +281,8 @@ describe('MessageActionsBox', () => {
chatProps: { client },
messageProps: { message: myMessage },
});
- await act(() => {
- fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
+ await act(async () => {
+ await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
});
expect(screen.queryByText(ACTION_TEXT)).toBeInTheDocument();
});
@@ -277,8 +301,8 @@ describe('MessageActionsBox', () => {
chatProps: { client },
messageProps: { message, threadList: true },
});
- await act(() => {
- fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
+ await act(async () => {
+ await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
});
expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument();
});
@@ -312,8 +336,8 @@ describe('MessageActionsBox', () => {
});
});
- await act(() => {
- fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
+ await act(async () => {
+ await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
});
expect(screen.queryByText(ACTION_TEXT)).toBeInTheDocument();
});
@@ -341,8 +365,8 @@ describe('MessageActionsBox', () => {
chatProps: { client },
messageProps: { message: messageWithoutID },
});
- await act(() => {
- fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
+ await act(async () => {
+ await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
});
expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument();
});
@@ -374,12 +398,14 @@ describe('MessageActionsBox', () => {
customUser: me,
});
- await act(() => {
- render(
+ await act(async () => {
+ await render(
-
-
+
+
+
+
,
);
@@ -405,9 +431,9 @@ describe('MessageActionsBox', () => {
chatProps: { client },
messageProps: { message },
});
- await act(() => {
- fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
- fireEvent.click(screen.getByText(ACTION_TEXT));
+ await act(async () => {
+ await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
+ await fireEvent.click(screen.getByText(ACTION_TEXT));
});
expect(channel.markUnread).toHaveBeenCalledWith(
expect.objectContaining({ message_id: message.id }),
@@ -430,9 +456,9 @@ describe('MessageActionsBox', () => {
chatProps: { client },
messageProps: { getMarkMessageUnreadSuccessNotification, message },
});
- await act(() => {
- fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
- fireEvent.click(screen.getByText(ACTION_TEXT));
+ await act(async () => {
+ await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
+ await fireEvent.click(screen.getByText(ACTION_TEXT));
});
expect(getMarkMessageUnreadSuccessNotification).toHaveBeenCalledWith(
expect.objectContaining(message),
@@ -455,9 +481,9 @@ describe('MessageActionsBox', () => {
chatProps: { client },
messageProps: { getMarkMessageUnreadErrorNotification, message },
});
- await act(() => {
- fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
- fireEvent.click(screen.getByText(ACTION_TEXT));
+ await act(async () => {
+ await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
+ await fireEvent.click(screen.getByText(ACTION_TEXT));
});
expect(getMarkMessageUnreadErrorNotification).toHaveBeenCalledWith(
expect.objectContaining(message),
From d45459b7672f59e2135a8d578a51c18a10d4634b Mon Sep 17 00:00:00 2001
From: martincupela
Date: Thu, 5 Sep 2024 15:38:06 +0200
Subject: [PATCH 07/29] test: fix tests rendering message actions
---
.../Message/__tests__/MessageOptions.test.js | 49 ++++----
.../MessageList/VirtualizedMessageList.tsx | 2 +-
.../VirtualizedMessageListComponents.test.js | 108 ++++++++++++++++--
.../VirtualizedMessageList.test.js.snap | 11 ++
...tualizedMessageListComponents.test.js.snap | 54 +++++++++
5 files changed, 192 insertions(+), 32 deletions(-)
diff --git a/src/components/Message/__tests__/MessageOptions.test.js b/src/components/Message/__tests__/MessageOptions.test.js
index fb3ce9b8a..4a6571980 100644
--- a/src/components/Message/__tests__/MessageOptions.test.js
+++ b/src/components/Message/__tests__/MessageOptions.test.js
@@ -21,6 +21,7 @@ import {
generateUser,
getTestClientWithUser,
} from '../../../mock-builders';
+import { DialogsManagerProvider } from '../../../context';
const MESSAGE_ACTIONS_TEST_ID = 'message-actions';
@@ -55,32 +56,34 @@ async function renderMessageOptions({
return render(
-
-
-
+
+ (
-
- ),
+ openThread: jest.fn(),
+ removeMessage: jest.fn(),
+ updateMessage: jest.fn(),
}}
>
-
-
-
-
-
-
+ (
+
+ ),
+ }}
+ >
+
+
+
+
+
+
+
,
);
}
diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx
index 89a556623..1b2b7157c 100644
--- a/src/components/MessageList/VirtualizedMessageList.tsx
+++ b/src/components/MessageList/VirtualizedMessageList.tsx
@@ -428,7 +428,7 @@ const VirtualizedMessageListWithContext = <
return (
<>
-
+
{!threadList && showUnreadMessagesNotification && (
)}
diff --git a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js
index 9230ade0a..b900ecf85 100644
--- a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js
+++ b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js
@@ -20,6 +20,7 @@ import {
ChannelStateProvider,
ChatProvider,
ComponentProvider,
+ DialogsManagerProvider,
TranslationProvider,
useMessageContext,
} from '../../../context';
@@ -38,7 +39,11 @@ const Wrapper = ({ children, componentContext = {} }) => (
- {children}
+
+
+ {children}
+
+
@@ -84,7 +89,16 @@ describe('VirtualizedMessageComponents', () => {
const CustomLoadingIndicator = () => Custom Loading Indicator
;
it('should render empty div in Header when not loading more messages', () => {
const { container } = renderElements();
- expect(container).toMatchInlineSnapshot(`
`);
+ expect(container).toMatchInlineSnapshot(`
+
+ `);
});
it('should render LoadingIndicator in Header when loading more messages', () => {
@@ -106,6 +120,12 @@ describe('VirtualizedMessageComponents', () => {
Custom Loading Indicator
+
`);
});
@@ -113,7 +133,16 @@ describe('VirtualizedMessageComponents', () => {
it('should not render custom LoadingIndicator in Header when not loading more messages', () => {
const componentContext = { LoadingIndicator: CustomLoadingIndicator };
const { container } = renderElements(, componentContext);
- expect(container).toMatchInlineSnapshot(`
`);
+ expect(container).toMatchInlineSnapshot(`
+
+ `);
});
// FIXME: this is a crazy pattern of having to set LoadingIndicator to null so that additionalVirtuosoProps.head can be rendered.
@@ -135,6 +164,12 @@ describe('VirtualizedMessageComponents', () => {
Custom head
+
`);
});
@@ -142,7 +177,16 @@ describe('VirtualizedMessageComponents', () => {
it('should not render custom head in Header when not loading more messages', () => {
const context = { head };
const { container } = renderElements();
- expect(container).toMatchInlineSnapshot(`
`);
+ expect(container).toMatchInlineSnapshot(`
+
+ `);
});
it('should render custom LoadingIndicator instead of head when loading more', () => {
@@ -158,6 +202,12 @@ describe('VirtualizedMessageComponents', () => {
Custom Loading Indicator
+
`);
});
@@ -176,7 +226,16 @@ describe('VirtualizedMessageComponents', () => {
it('should render empty for thread by default', () => {
const { container } = renderElements( );
- expect(container).toMatchInlineSnapshot(`
`);
+ expect(container).toMatchInlineSnapshot(`
+
+ `);
});
it('should render custom EmptyStateIndicator for main message list', () => {
const { container } = renderElements( , componentContext);
@@ -194,7 +253,16 @@ describe('VirtualizedMessageComponents', () => {
it('should render empty if EmptyStateIndicator nullified', () => {
const componentContext = { EmptyStateIndicator: NullEmptyStateIndicator };
const { container } = renderElements( , componentContext);
- expect(container).toMatchInlineSnapshot(`
`);
+ expect(container).toMatchInlineSnapshot(`
+
+ `);
});
it('should render empty in thread if EmptyStateIndicator nullified', () => {
@@ -203,14 +271,32 @@ describe('VirtualizedMessageComponents', () => {
,
componentContext,
);
- expect(container).toMatchInlineSnapshot(`
`);
+ expect(container).toMatchInlineSnapshot(`
+
+ `);
});
});
describe('Footer', () => {
it('should render nothing in Footer by default', () => {
const { container } = renderElements();
- expect(container).toMatchInlineSnapshot(`
`);
+ expect(container).toMatchInlineSnapshot(`
+
+ `);
});
it('should render custom TypingIndicator in Footer', () => {
const TypingIndicator = () => Custom TypingIndicator
;
@@ -221,6 +307,12 @@ describe('VirtualizedMessageComponents', () => {
Custom TypingIndicator
+
`);
});
diff --git a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap
index 4ea760c01..052d5d589 100644
--- a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap
+++ b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap
@@ -65,6 +65,17 @@ exports[`VirtualizedMessageList should render the list without any message 1`] =
+
diff --git a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageListComponents.test.js.snap b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageListComponents.test.js.snap
index 47619c06a..0ab1b8f65 100644
--- a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageListComponents.test.js.snap
+++ b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageListComponents.test.js.snap
@@ -7,6 +7,12 @@ exports[`VirtualizedMessageComponents EmptyPlaceholder should render custom Empt
>
Custom EmptyStateIndicator
+
`;
@@ -17,6 +23,12 @@ exports[`VirtualizedMessageComponents EmptyPlaceholder should render custom Empt
>
Custom EmptyStateIndicator
+
`;
@@ -45,6 +57,12 @@ exports[`VirtualizedMessageComponents EmptyPlaceholder should render for main me
No chats here yet…
+
`;
@@ -94,6 +112,12 @@ exports[`VirtualizedMessageComponents Header should not render custom head in He
+
`;
@@ -143,6 +167,12 @@ exports[`VirtualizedMessageComponents Header should render LoadingIndicator in H
+
`;
@@ -152,6 +182,12 @@ exports[`VirtualizedMessageComponents Item should render wrapper with custom cla
class="XXX"
data-item-index="10000000"
/>
+
`;
@@ -161,6 +197,12 @@ exports[`VirtualizedMessageComponents Item should render wrapper with custom cla
class="XXX"
data-item-index="10000000"
/>
+
`;
@@ -170,6 +212,12 @@ exports[`VirtualizedMessageComponents Item should render wrapper without custom
class="str-chat__virtual-list-message-wrapper str-chat__li str-chat__li--single"
data-item-index="10000000"
/>
+
`;
@@ -179,5 +227,11 @@ exports[`VirtualizedMessageComponents Item should render wrapper without custom
class="str-chat__virtual-list-message-wrapper str-chat__li"
data-item-index="10000000"
/>
+
`;
From ba89493d826d05fa292a9f8f2ef139698ad75410 Mon Sep 17 00:00:00 2001
From: martincupela
Date: Thu, 5 Sep 2024 15:42:10 +0200
Subject: [PATCH 08/29] feat: assign static id to DialogsManager inside
MessageList
---
src/components/MessageList/MessageList.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx
index 785982d27..0d25667a5 100644
--- a/src/components/MessageList/MessageList.tsx
+++ b/src/components/MessageList/MessageList.tsx
@@ -226,7 +226,7 @@ const MessageListWithContext = <
return (
-
+
{!threadList && showUnreadMessagesNotification && (
)}
From 993358f67ba83046e221b92270b47cd56d93ee9a Mon Sep 17 00:00:00 2001
From: martincupela
Date: Thu, 5 Sep 2024 16:05:57 +0200
Subject: [PATCH 09/29] docs: fix todo comment
---
docusaurus/docs/React/guides/theming/message-ui.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docusaurus/docs/React/guides/theming/message-ui.mdx b/docusaurus/docs/React/guides/theming/message-ui.mdx
index 52e092be8..65cfe632a 100644
--- a/docusaurus/docs/React/guides/theming/message-ui.mdx
+++ b/docusaurus/docs/React/guides/theming/message-ui.mdx
@@ -387,7 +387,7 @@ const CustomMessageUi = () => {
Message grouping is being managed automatically by the SDK and each parent element (which holds our message UI) receives an appropriate class name based on which we can adjust our rules to display metadata elements only when it's appropriate to make our UI look less busy.
-{/_ TODO: link to grouping logic (maybe how to adjust it if needed) _/}
+[//]: # 'TODO: link to grouping logic (maybe how to adjust it if needed)'
```css
.custom-message-ui__metadata {
From b557e25d39b6a6e96d0124c9f65fd480a23076d7 Mon Sep 17 00:00:00 2001
From: martincupela
Date: Thu, 5 Sep 2024 17:13:15 +0200
Subject: [PATCH 10/29] feat: handle focus within dialog
---
src/components/Dialog/DialogAnchor.tsx | 48 +++++++++++++++++++
.../MessageActions/MessageActions.tsx | 1 +
2 files changed, 49 insertions(+)
diff --git a/src/components/Dialog/DialogAnchor.tsx b/src/components/Dialog/DialogAnchor.tsx
index 20c7434f0..8eac6aa75 100644
--- a/src/components/Dialog/DialogAnchor.tsx
+++ b/src/components/Dialog/DialogAnchor.tsx
@@ -49,14 +49,18 @@ export function useDialogAnchor({
type DialogAnchorProps = PropsWithChildren> & {
id: string;
+ focus?: boolean;
+ trapFocus?: boolean;
} & ComponentProps<'div'>;
export const DialogAnchor = ({
children,
className,
+ focus = true,
id,
placement = 'auto',
referenceElement = null,
+ trapFocus,
...restDivProps
}: DialogAnchorProps) => {
const open = useDialogIsOpen(id);
@@ -66,6 +70,43 @@ export const DialogAnchor = ({
referenceElement,
});
+ // handle focus and focus trap inside the dialog
+ useEffect(() => {
+ if (!popperElementRef.current || !focus || !open) return;
+ const container = popperElementRef.current;
+ container.focus();
+
+ if (!trapFocus) return;
+ const handleKeyDownWithTabRoundRobin = (event: KeyboardEvent) => {
+ if (event.key !== 'Tab') return;
+
+ const focusableElements = getFocusableElements(container);
+ if (focusableElements.length === 0) return;
+
+ const firstElement = focusableElements[0] as HTMLElement;
+ const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
+ if (firstElement === lastElement) {
+ event.preventDefault();
+ firstElement.focus();
+ }
+
+ // Trap focus within the group
+ if (event.shiftKey && document.activeElement === firstElement) {
+ // If Shift + Tab on the first element, move focus to the last element
+ event.preventDefault();
+ lastElement.focus();
+ } else if (!event.shiftKey && document.activeElement === lastElement) {
+ // If Tab on the last element, move focus to the first element
+ event.preventDefault();
+ firstElement.focus();
+ }
+ };
+
+ container.addEventListener('keydown', handleKeyDownWithTabRoundRobin);
+
+ return () => container.removeEventListener('keydown', handleKeyDownWithTabRoundRobin);
+ }, [focus, popperElementRef, open, trapFocus]);
+
return (
{children}
);
};
+
+function getFocusableElements(container: HTMLElement) {
+ return container.querySelectorAll(
+ 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])',
+ );
+}
diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx
index 41dd364e7..cdae62daa 100644
--- a/src/components/MessageActions/MessageActions.tsx
+++ b/src/components/MessageActions/MessageActions.tsx
@@ -149,6 +149,7 @@ export const MessageActions = <
id={dialogId}
placement={isMine ? 'top-end' : 'top-start'}
referenceElement={actionsBoxButtonRef.current}
+ trapFocus
>
Date: Fri, 6 Sep 2024 15:52:07 +0200
Subject: [PATCH 11/29] feat: prevent rendering dialog contents if not open
---
src/components/Dialog/DialogAnchor.tsx | 98 +++++++++++---------------
1 file changed, 43 insertions(+), 55 deletions(-)
diff --git a/src/components/Dialog/DialogAnchor.tsx b/src/components/Dialog/DialogAnchor.tsx
index 8eac6aa75..db27d8ee6 100644
--- a/src/components/Dialog/DialogAnchor.tsx
+++ b/src/components/Dialog/DialogAnchor.tsx
@@ -1,9 +1,10 @@
import clsx from 'clsx';
import { Placement } from '@popperjs/core';
-import React, { ComponentProps, PropsWithChildren, useEffect, useRef } from 'react';
+import React, { ComponentProps, PropsWithChildren, useEffect, useState } from 'react';
+import { FocusScope } from '@react-aria/focus';
import { usePopper } from 'react-popper';
-import { useDialogIsOpen } from './hooks';
import { DialogPortalEntry } from './DialogPortal';
+import { useDialog, useDialogIsOpen } from './hooks';
export interface DialogAnchorOptions {
open: boolean;
@@ -16,8 +17,8 @@ export function useDialogAnchor({
placement,
referenceElement,
}: DialogAnchorOptions) {
- const popperElementRef = useRef(null);
- const { attributes, styles, update } = usePopper(referenceElement, popperElementRef.current, {
+ const [popperElement, setPopperElement] = useState(null);
+ const { attributes, styles, update } = usePopper(referenceElement, popperElement, {
modifiers: [
{
name: 'eventListeners',
@@ -33,16 +34,17 @@ export function useDialogAnchor({
});
useEffect(() => {
- if (open) {
+ if (open && popperElement) {
// Since the popper's reference element might not be (and usually is not) visible
// all the time, it's safer to force popper update before showing it.
+ // update is non-null only if popperElement is non-null
update?.();
}
- }, [open, update]);
+ }, [open, popperElement, update]);
return {
attributes,
- popperElementRef,
+ setPopperElement,
styles,
};
}
@@ -63,69 +65,55 @@ export const DialogAnchor = ({
trapFocus,
...restDivProps
}: DialogAnchorProps) => {
+ const dialog = useDialog({ id });
const open = useDialogIsOpen(id);
- const { attributes, popperElementRef, styles } = useDialogAnchor({
+ const { attributes, setPopperElement, styles } = useDialogAnchor({
open,
placement,
referenceElement,
});
- // handle focus and focus trap inside the dialog
useEffect(() => {
- if (!popperElementRef.current || !focus || !open) return;
- const container = popperElementRef.current;
- container.focus();
-
- if (!trapFocus) return;
- const handleKeyDownWithTabRoundRobin = (event: KeyboardEvent) => {
- if (event.key !== 'Tab') return;
-
- const focusableElements = getFocusableElements(container);
- if (focusableElements.length === 0) return;
+ if (!open) return;
+ const hideOnEscape = (event: KeyboardEvent) => {
+ if (event.key !== 'Escape') return;
+ dialog?.close();
+ };
- const firstElement = focusableElements[0] as HTMLElement;
- const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
- if (firstElement === lastElement) {
- event.preventDefault();
- firstElement.focus();
- }
+ document.addEventListener('keyup', hideOnEscape);
- // Trap focus within the group
- if (event.shiftKey && document.activeElement === firstElement) {
- // If Shift + Tab on the first element, move focus to the last element
- event.preventDefault();
- lastElement.focus();
- } else if (!event.shiftKey && document.activeElement === lastElement) {
- // If Tab on the last element, move focus to the first element
- event.preventDefault();
- firstElement.focus();
- }
+ return () => {
+ document.removeEventListener('keyup', hideOnEscape);
};
+ }, [dialog, open]);
- container.addEventListener('keydown', handleKeyDownWithTabRoundRobin);
+ useEffect(() => {
+ if (!open) {
+ // setting element reference back to null allows to re-run the usePopper component once the component is re-rendered
+ setPopperElement(null);
+ }
+ }, [open, setPopperElement]);
- return () => container.removeEventListener('keydown', handleKeyDownWithTabRoundRobin);
- }, [focus, popperElementRef, open, trapFocus]);
+ // prevent rendering the dialog contents if the dialog should not be open / shown
+ if (!open) {
+ return null;
+ }
return (
-
- {children}
-
+
+
+ {children}
+
+
);
};
-
-function getFocusableElements(container: HTMLElement) {
- return container.querySelectorAll(
- 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])',
- );
-}
From 07eb2610f8d9ca42de053947736fc959c8bb5cdc Mon Sep 17 00:00:00 2001
From: martincupela
Date: Fri, 6 Sep 2024 16:34:30 +0200
Subject: [PATCH 12/29] refactor: do not register event listeners by
MessageActions component
---
.../Message/__tests__/MessageOptions.test.js | 4 +-
.../Message/__tests__/MessageText.test.js | 1 -
.../MessageActions/MessageActions.tsx | 50 +------
.../__tests__/MessageActions.test.js | 127 +++++-------------
4 files changed, 42 insertions(+), 140 deletions(-)
diff --git a/src/components/Message/__tests__/MessageOptions.test.js b/src/components/Message/__tests__/MessageOptions.test.js
index 4a6571980..8b9309291 100644
--- a/src/components/Message/__tests__/MessageOptions.test.js
+++ b/src/components/Message/__tests__/MessageOptions.test.js
@@ -34,9 +34,7 @@ const defaultMessageProps = {
onReactionListClick: () => {},
threadList: false,
};
-const defaultOptionsProps = {
- messageWrapperRef: { current: document.createElement('div') },
-};
+const defaultOptionsProps = {};
function generateAliceMessage(messageOptions) {
return generateMessage({
diff --git a/src/components/Message/__tests__/MessageText.test.js b/src/components/Message/__tests__/MessageText.test.js
index fcac6a4b9..953fca0e8 100644
--- a/src/components/Message/__tests__/MessageText.test.js
+++ b/src/components/Message/__tests__/MessageText.test.js
@@ -43,7 +43,6 @@ const onMentionsClickMock = jest.fn();
const defaultProps = {
initialMessage: false,
message: generateMessage(),
- messageWrapperRef: { current: document.createElement('div') },
onReactionListClick: () => {},
threadList: false,
};
diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx
index cdae62daa..1567a9f99 100644
--- a/src/components/MessageActions/MessageActions.tsx
+++ b/src/components/MessageActions/MessageActions.tsx
@@ -1,5 +1,5 @@
import clsx from 'clsx';
-import React, { ElementRef, PropsWithChildren, useCallback, useEffect, useRef } from 'react';
+import React, { ElementRef, PropsWithChildren, useCallback, useRef } from 'react';
import { MessageActionsBox } from './MessageActionsBox';
@@ -31,8 +31,6 @@ export type MessageActionsProps<
customWrapperClass?: string;
/* If true, renders the wrapper component as a `span`, not a `div` */
inline?: boolean;
- /* React mutable ref that can be placed on the message root `div` of MessageActions component */
- messageWrapperRef?: React.RefObject;
/* Function that returns whether the message was sent by the connected user */
mine?: () => boolean;
};
@@ -53,7 +51,6 @@ export const MessageActions = <
handlePin: propHandlePin,
inline,
message: propMessage,
- messageWrapperRef,
mine,
} = props;
@@ -101,50 +98,12 @@ export const MessageActions = <
messageActions,
});
- const messageDeletedAt = !!message?.deleted_at;
-
- const hideOptions = useCallback(
- (event: MouseEvent | KeyboardEvent) => {
- if (event instanceof KeyboardEvent && event.key !== 'Escape') {
- return;
- }
- dialog?.close();
- },
- [dialog],
- );
-
- useEffect(() => {
- if (messageWrapperRef?.current) {
- messageWrapperRef.current.addEventListener('mouseleave', hideOptions);
- }
- }, [hideOptions, messageWrapperRef]);
-
- useEffect(() => {
- if (messageDeletedAt) {
- document.removeEventListener('click', hideOptions);
- }
- }, [hideOptions, messageDeletedAt]);
-
- useEffect(() => {
- if (!dialogIsOpen) return;
-
- document.addEventListener('keyup', hideOptions);
-
- return () => {
- document.removeEventListener('keyup', hideOptions);
- };
- }, [dialog, dialogIsOpen, hideOptions]);
-
const actionsBoxButtonRef = useRef>(null);
if (!renderMessageActions) return null;
return (
-
+
@@ -180,11 +140,10 @@ export const MessageActions = <
export type MessageActionsWrapperProps = {
customWrapperClass?: string;
inline?: boolean;
- toggleOpen?: () => void;
};
const MessageActionsWrapper = (props: PropsWithChildren) => {
- const { children, customWrapperClass, inline, toggleOpen } = props;
+ const { children, customWrapperClass, inline } = props;
const defaultWrapperClass = clsx(
'str-chat__message-simple__actions__action',
@@ -195,7 +154,6 @@ const MessageActionsWrapper = (props: PropsWithChildren{children};
diff --git a/src/components/MessageActions/__tests__/MessageActions.test.js b/src/components/MessageActions/__tests__/MessageActions.test.js
index cbb350f20..1783b69af 100644
--- a/src/components/MessageActions/__tests__/MessageActions.test.js
+++ b/src/components/MessageActions/__tests__/MessageActions.test.js
@@ -66,24 +66,30 @@ function renderMessageActions(customProps, renderer = render) {
const dialogOverlayTestId = 'str-chat__dialog-overlay';
const messageActionsTestId = 'message-actions';
+
+const toggleOpenMessageActions = async () => {
+ await act(async () => {
+ await fireEvent.click(screen.getByRole('button'));
+ });
+};
describe(' component', () => {
afterEach(cleanup);
beforeEach(jest.clearAllMocks);
- it('should render correctly', () => {
+ it('should render correctly when not open', () => {
const tree = renderMessageActions({}, testRenderer.create);
expect(tree.toJSON()).toMatchInlineSnapshot(`
Array [
component', () => {
});
it('should open message actions box on click', async () => {
- const { getByTestId } = renderMessageActions();
- expect(MessageActionsBoxMock).toHaveBeenCalledWith(
- expect.objectContaining({ open: false }),
- {},
- );
+ renderMessageActions();
+ expect(MessageActionsBoxMock).not.toHaveBeenCalled();
const dialogOverlay = screen.getByTestId(dialogOverlayTestId);
- expect(dialogOverlay.children).toHaveLength(1);
- await act(async () => {
- await fireEvent.click(getByTestId(messageActionsTestId));
- });
+ expect(dialogOverlay.children).toHaveLength(0);
+ await toggleOpenMessageActions();
expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
expect.objectContaining({ open: true }),
{},
);
- expect(dialogOverlay.children).toHaveLength(1);
+ expect(dialogOverlay.children.length).toBeGreaterThan(0);
});
it('should close message actions box on icon click if already opened', async () => {
- const { getByTestId } = renderMessageActions();
- expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
- expect.objectContaining({ open: false }),
- {},
- );
- await act(async () => {
- await fireEvent.click(getByTestId(messageActionsTestId));
- });
+ renderMessageActions();
+ const dialogOverlay = screen.getByTestId(dialogOverlayTestId);
+ expect(MessageActionsBoxMock).not.toHaveBeenCalled();
+ await toggleOpenMessageActions();
expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
expect.objectContaining({ open: true }),
{},
);
- await act(async () => {
- await fireEvent.click(getByTestId(messageActionsTestId));
- });
- expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
- expect.objectContaining({ open: false }),
- {},
- );
+ await toggleOpenMessageActions();
+ expect(dialogOverlay.children).toHaveLength(0);
});
it('should close message actions box when user clicks overlay if it is already opened', async () => {
- const { getByRole } = renderMessageActions();
- await act(async () => {
- await fireEvent.click(getByRole('button'));
- });
+ renderMessageActions();
+ await toggleOpenMessageActions();
expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
expect.objectContaining({ open: true }),
{},
@@ -175,53 +165,24 @@ describe(' component', () => {
await act(async () => {
await fireEvent.click(dialogOverlay);
});
- expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
- expect.objectContaining({ open: false }),
- {},
- );
+ expect(MessageActionsBoxMock).toHaveBeenCalledTimes(1);
+ expect(dialogOverlay.children).toHaveLength(0);
});
it('should close message actions box when user presses Escape key', async () => {
- const { getByRole } = renderMessageActions();
- await act(async () => {
- await fireEvent.click(getByRole('button'));
- });
- expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
- expect.objectContaining({ open: true }),
- {},
- );
+ renderMessageActions();
+ const dialogOverlay = screen.getByTestId(dialogOverlayTestId);
+ await toggleOpenMessageActions();
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', async () => {
- const customProps = {
- messageWrapperRef: { current: wrapperMock },
- };
- const { getByRole } = renderMessageActions(customProps);
- await act(async () => {
- await fireEvent.click(getByRole('button'));
- });
- expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
- expect.objectContaining({ open: true }),
- {},
- );
- await act(async () => {
- await fireEvent.mouseLeave(customProps.messageWrapperRef.current);
- });
- expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
- expect.objectContaining({ open: false }),
- {},
- );
+ expect(MessageActionsBoxMock).toHaveBeenCalledTimes(1);
+ expect(dialogOverlay.children).toHaveLength(0);
});
- it('should render the message actions box correctly', () => {
+ it('should render the message actions box correctly', async () => {
renderMessageActions();
+ await toggleOpenMessageActions();
expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
expect.objectContaining({
getMessageActions: defaultProps.getMessageActions,
@@ -232,40 +193,26 @@ describe(' component', () => {
handlePin: defaultProps.handlePin,
isUserMuted: expect.any(Function),
mine: false,
- open: false,
+ open: true,
}),
{},
);
});
it('should not register click and keyup event listeners to close actions box until opened', async () => {
- const { getByRole } = renderMessageActions();
+ renderMessageActions();
const addEventListener = jest.spyOn(document, 'addEventListener');
expect(document.addEventListener).not.toHaveBeenCalled();
- await act(async () => {
- await fireEvent.click(getByRole('button'));
- });
+ await toggleOpenMessageActions();
expect(document.addEventListener).toHaveBeenCalledWith('keyup', expect.any(Function));
addEventListener.mockClear();
});
- it('should not remove click and keyup event listeners when unmounted if actions box not opened', () => {
+ it('should remove keyup event listener when unmounted if actions box not opened', async () => {
const { unmount } = renderMessageActions();
const removeEventListener = jest.spyOn(document, 'removeEventListener');
expect(document.removeEventListener).not.toHaveBeenCalled();
- unmount();
- expect(document.removeEventListener).not.toHaveBeenCalledWith('click', expect.any(Function));
- expect(document.removeEventListener).not.toHaveBeenCalledWith('keyup', expect.any(Function));
- removeEventListener.mockClear();
- });
-
- it('should remove event listener when unmounted', async () => {
- const { getByRole, unmount } = renderMessageActions();
- const removeEventListener = jest.spyOn(document, 'removeEventListener');
- await act(async () => {
- await fireEvent.click(getByRole('button'));
- });
- expect(document.removeEventListener).not.toHaveBeenCalled();
+ await toggleOpenMessageActions();
unmount();
expect(document.removeEventListener).toHaveBeenCalledWith('keyup', expect.any(Function));
removeEventListener.mockClear();
@@ -283,13 +230,13 @@ describe(' component', () => {
component', () => {
Date: Fri, 6 Sep 2024 17:31:42 +0200
Subject: [PATCH 13/29] test: open MessageActionsBox first in
MessageActionsBox.test.js
---
.../MessageActions/MessageActions.tsx | 1 +
.../__tests__/MessageActions.test.js | 3 ++
.../__tests__/MessageActionsBox.test.js | 54 ++++++++++---------
3 files changed, 34 insertions(+), 24 deletions(-)
diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx
index 1567a9f99..7ee4145c0 100644
--- a/src/components/MessageActions/MessageActions.tsx
+++ b/src/components/MessageActions/MessageActions.tsx
@@ -128,6 +128,7 @@ export const MessageActions = <
aria-haspopup='true'
aria-label={t('aria/Open Message Actions Menu')}
className='str-chat__message-actions-box-button'
+ data-testid='message-actions-toggle-button'
onClick={dialog?.toggleSingle}
ref={actionsBoxButtonRef}
>
diff --git a/src/components/MessageActions/__tests__/MessageActions.test.js b/src/components/MessageActions/__tests__/MessageActions.test.js
index 1783b69af..13122e5e7 100644
--- a/src/components/MessageActions/__tests__/MessageActions.test.js
+++ b/src/components/MessageActions/__tests__/MessageActions.test.js
@@ -89,6 +89,7 @@ describe(' component', () => {
aria-haspopup="true"
aria-label="Open Message Actions Menu"
className="str-chat__message-actions-box-button"
+ data-testid="message-actions-toggle-button"
onClick={[Function]}
>
component', () => {
aria-haspopup="true"
aria-label="Open Message Actions Menu"
className="str-chat__message-actions-box-button"
+ data-testid="message-actions-toggle-button"
onClick={[Function]}
>
component', () => {
aria-haspopup="true"
aria-label="Open Message Actions Menu"
className="str-chat__message-actions-box-button"
+ data-testid="message-actions-toggle-button"
onClick={[Function]}
>
{
+ await act(async () => {
+ await fireEvent.click(screen.getAllByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)[i]);
+ });
+};
+
async function renderComponent(boxProps, messageContext = {}) {
const { client } = await initClientWithChannels();
return render(
@@ -171,7 +178,6 @@ describe('MessageActionsBox', () => {
describe('mark message unread', () => {
afterEach(jest.restoreAllMocks);
const ACTION_TEXT = 'Mark as unread';
- const TOGGLE_ACTIONS_BUTTON_TEST_ID = 'message-actions';
const me = generateUser();
const otherUser = generateUser();
const message = generateMessage({ user: otherUser });
@@ -254,9 +260,7 @@ describe('MessageActionsBox', () => {
chatProps: { client },
messageProps: { message },
});
- await act(async () => {
- await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
- });
+ await toggleOpenMessageActions();
expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument();
});
@@ -281,9 +285,7 @@ describe('MessageActionsBox', () => {
chatProps: { client },
messageProps: { message: myMessage },
});
- await act(async () => {
- await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
- });
+ await toggleOpenMessageActions();
expect(screen.queryByText(ACTION_TEXT)).toBeInTheDocument();
});
@@ -301,9 +303,7 @@ describe('MessageActionsBox', () => {
chatProps: { client },
messageProps: { message, threadList: true },
});
- await act(async () => {
- await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
- });
+ await toggleOpenMessageActions();
expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument();
});
@@ -336,9 +336,7 @@ describe('MessageActionsBox', () => {
});
});
- await act(async () => {
- await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
- });
+ await toggleOpenMessageActions();
expect(screen.queryByText(ACTION_TEXT)).toBeInTheDocument();
});
@@ -365,20 +363,18 @@ describe('MessageActionsBox', () => {
chatProps: { client },
messageProps: { message: messageWithoutID },
});
- await act(async () => {
- await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
- });
+ await toggleOpenMessageActions();
expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument();
});
- it('should be displayed as an option for messages other than message marked unread', async () => {
+ it('should be displayed as an option for messages not marked and marked unread', async () => {
const otherMsg = generateMessage({
- created_at: new Date(new Date(message.created_at).getTime() + 1000),
+ created_at: new Date(new Date(message.created_at).getTime() + 2000),
});
const read = [
{
- first_unread_message_id: message.id,
- last_read: new Date(new Date(message.created_at).getTime() - 1000),
+ first_unread_message_id: otherMsg.id,
+ last_read: new Date(new Date(otherMsg.created_at).getTime() - 1000),
// last_read_message_id: message.id, // optional
unread_messages: 2,
user: me,
@@ -410,10 +406,17 @@ describe('MessageActionsBox', () => {
,
);
});
-
- const [actionsBox1, actionsBox2] = screen.getAllByTestId('message-actions-box');
- expect(actionsBox1).toHaveTextContent(ACTION_TEXT);
- expect(actionsBox2).toHaveTextContent(ACTION_TEXT);
+ await toggleOpenMessageActions(0);
+ let boxes = screen.getAllByTestId('message-actions-box');
+ // eslint-disable-next-line jest-dom/prefer-in-document
+ expect(boxes).toHaveLength(1);
+ expect(boxes[0]).toHaveTextContent(ACTION_TEXT);
+
+ await toggleOpenMessageActions(1);
+ boxes = screen.getAllByTestId('message-actions-box');
+ // eslint-disable-next-line jest-dom/prefer-in-document
+ expect(boxes).toHaveLength(1);
+ expect(boxes[0]).toHaveTextContent(ACTION_TEXT);
});
it('should be displayed and execute API request', async () => {
@@ -431,6 +434,7 @@ describe('MessageActionsBox', () => {
chatProps: { client },
messageProps: { message },
});
+ await toggleOpenMessageActions();
await act(async () => {
await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
await fireEvent.click(screen.getByText(ACTION_TEXT));
@@ -456,6 +460,7 @@ describe('MessageActionsBox', () => {
chatProps: { client },
messageProps: { getMarkMessageUnreadSuccessNotification, message },
});
+ await toggleOpenMessageActions();
await act(async () => {
await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
await fireEvent.click(screen.getByText(ACTION_TEXT));
@@ -481,6 +486,7 @@ describe('MessageActionsBox', () => {
chatProps: { client },
messageProps: { getMarkMessageUnreadErrorNotification, message },
});
+ await toggleOpenMessageActions();
await act(async () => {
await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID));
await fireEvent.click(screen.getByText(ACTION_TEXT));
From af6b94b6a9659231ec1ea1d8c8ab06d68fbeb913 Mon Sep 17 00:00:00 2001
From: martincupela
Date: Fri, 6 Sep 2024 18:24:49 +0200
Subject: [PATCH 14/29] feat: control ReactionsSelector dialog display
---
src/components/Message/Message.tsx | 21 +-
src/components/Message/MessageOptions.tsx | 36 +-
src/components/Message/MessageSimple.tsx | 23 +-
.../Message/__tests__/MessageOptions.test.js | 83 +++-
.../Message/__tests__/QuotedMessage.test.js | 9 +-
.../__tests__/useReactionHandler.test.js | 197 +---------
.../Message/hooks/useReactionHandler.ts | 94 +----
src/components/Message/utils.tsx | 4 +
src/components/Reactions/ReactionSelector.tsx | 360 +++++++++---------
.../Reactions/ReactionSelectorWithButton.tsx | 54 +++
.../__tests__/ReactionSelector.test.js | 15 +-
src/context/MessageContext.tsx | 12 +-
12 files changed, 366 insertions(+), 542 deletions(-)
create mode 100644 src/components/Reactions/ReactionSelectorWithButton.tsx
diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx
index 3b6ce5e6a..c9a486a96 100644
--- a/src/components/Message/Message.tsx
+++ b/src/components/Message/Message.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useMemo, useRef } from 'react';
+import React, { useCallback, useMemo } from 'react';
import {
useActionHandler,
@@ -10,7 +10,6 @@ import {
useMuteHandler,
useOpenThreadHandler,
usePinHandler,
- useReactionClick,
useReactionHandler,
useReactionsFetcher,
useRetryHandler,
@@ -44,14 +43,10 @@ type MessageContextPropsToPick =
| 'handleReaction'
| 'handleFetchReactions'
| 'handleRetry'
- | 'isReactionEnabled'
| 'mutes'
| 'onMentionsClickMessage'
| 'onMentionsHoverMessage'
- | 'onReactionListClick'
- | 'reactionSelectorRef'
| 'reactionDetailsSort'
- | 'showDetailedReactions'
| 'sortReactions'
| 'sortReactionDetails';
@@ -218,8 +213,6 @@ export const Message = <
const { addNotification } = useChannelActionContext('Message');
const { highlightedMessageId, mutes } = useChannelStateContext('Message');
- const reactionSelectorRef = useRef(null);
-
const handleAction = useActionHandler(message);
const handleOpenThread = useOpenThreadHandler(message, propOpenThread);
const handleReaction = useReactionHandler(message);
@@ -264,13 +257,6 @@ export const Message = <
notify: addNotification,
});
- const { isReactionEnabled, onReactionListClick, showDetailedReactions } = useReactionClick(
- message,
- reactionSelectorRef,
- undefined,
- closeReactionSelectorOnClick,
- );
-
const highlighted = highlightedMessageId === message.id;
return (
@@ -278,6 +264,7 @@ export const Message = <
additionalMessageInputProps={props.additionalMessageInputProps}
autoscrollToBottom={props.autoscrollToBottom}
canPin={canPin}
+ closeReactionSelectorOnClick={closeReactionSelectorOnClick}
customMessageActions={props.customMessageActions}
disableQuotedMessages={props.disableQuotedMessages}
endOfGroup={props.endOfGroup}
@@ -297,7 +284,6 @@ export const Message = <
handleRetry={handleRetry}
highlighted={highlighted}
initialMessage={props.initialMessage}
- isReactionEnabled={isReactionEnabled}
lastReceivedId={props.lastReceivedId}
message={message}
Message={props.Message}
@@ -306,15 +292,12 @@ export const Message = <
mutes={mutes}
onMentionsClickMessage={onMentionsClick}
onMentionsHoverMessage={onMentionsHover}
- onReactionListClick={onReactionListClick}
onUserClick={props.onUserClick}
onUserHover={props.onUserHover}
pinPermissions={props.pinPermissions}
reactionDetailsSort={reactionDetailsSort}
- reactionSelectorRef={reactionSelectorRef}
readBy={props.readBy}
renderText={props.renderText}
- showDetailedReactions={showDetailedReactions}
sortReactionDetails={sortReactionDetails}
sortReactions={sortReactions}
threadList={props.threadList}
diff --git a/src/components/Message/MessageOptions.tsx b/src/components/Message/MessageOptions.tsx
index 760bd1c46..f8890ea21 100644
--- a/src/components/Message/MessageOptions.tsx
+++ b/src/components/Message/MessageOptions.tsx
@@ -6,13 +6,15 @@ import {
ThreadIcon as DefaultThreadIcon,
} from './icons';
import { MESSAGE_ACTIONS } from './utils';
-
import { MessageActions } from '../MessageActions';
+import { useTranslationContext } from '../../context';
import { MessageContextValue, useMessageContext } from '../../context/MessageContext';
import type { DefaultStreamChatGenerics, IconProps } from '../../types/types';
-import { useTranslationContext } from '../../context';
+import { ReactionSelectorWithButton } from '../Reactions/ReactionSelectorWithButton';
+import { useDialogIsOpen } from '../Dialog';
+import clsx from 'clsx';
export type MessageOptionsProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
@@ -21,8 +23,6 @@ export type MessageOptionsProps<
ActionsIcon?: React.ComponentType;
/* If true, show the `ThreadIcon` and enable navigation into a `Thread` component. */
displayReplies?: boolean;
- /* React mutable ref that can be placed on the message root `div` of MessageActions component */
- messageWrapperRef?: React.RefObject;
/* 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. */
@@ -40,7 +40,6 @@ const UnMemoizedMessageOptions = <
ActionsIcon = DefaultActionsIcon,
displayReplies = true,
handleOpenThread: propHandleOpenThread,
- messageWrapperRef,
ReactionIcon = DefaultReactionIcon,
theme = 'simple',
ThreadIcon = DefaultThreadIcon,
@@ -51,13 +50,12 @@ const UnMemoizedMessageOptions = <
handleOpenThread: contextHandleOpenThread,
initialMessage,
message,
- onReactionListClick,
- showDetailedReactions,
threadList,
} = useMessageContext('MessageOptions');
const { t } = useTranslationContext('MessageOptions');
-
+ const messageActionsDialogIsOpen = useDialogIsOpen(`message-actions--${message.id}`);
+ const reactionSelectorDialogIsOpen = useDialogIsOpen(`reaction-selector--${message.id}`);
const handleOpenThread = propHandleOpenThread || contextHandleOpenThread;
const messageActions = getMessageActions();
@@ -78,11 +76,15 @@ const UnMemoizedMessageOptions = <
return null;
}
- const rootClassName = `str-chat__message-${theme}__actions str-chat__message-options`;
-
return (
-
-
+
+
{shouldShowReplies && (
)}
{shouldShowReactions && (
-
-
-
+
)}
);
diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx
index 33a909521..bc678e1a0 100644
--- a/src/components/Message/MessageSimple.tsx
+++ b/src/components/Message/MessageSimple.tsx
@@ -22,10 +22,7 @@ import { CUSTOM_MESSAGE_TYPE } from '../../constants/messageTypes';
import { EditMessageForm as DefaultEditMessageForm, MessageInput } from '../MessageInput';
import { MML } from '../MML';
import { Modal } from '../Modal';
-import {
- ReactionsList as DefaultReactionList,
- ReactionSelector as DefaultReactionSelector,
-} from '../Reactions';
+import { ReactionsList as DefaultReactionList } from '../Reactions';
import { MessageBounceModal } from '../MessageBounce/MessageBounceModal';
import { useChatContext } from '../../context/ChatContext';
@@ -59,13 +56,10 @@ const MessageSimpleWithContext = <
handleRetry,
highlighted,
isMyMessage,
- isReactionEnabled,
message,
onUserClick,
onUserHover,
- reactionSelectorRef,
renderText,
- showDetailedReactions,
threadList,
} = props;
@@ -83,7 +77,7 @@ const MessageSimpleWithContext = <
MessageRepliesCountButton = DefaultMessageRepliesCountButton,
MessageStatus = DefaultMessageStatus,
MessageTimestamp = DefaultMessageTimestamp,
- ReactionSelector = DefaultReactionSelector,
+
ReactionsList = DefaultReactionList,
PinIndicator,
} = useComponentContext
('MessageSimple');
@@ -100,14 +94,6 @@ const MessageSimpleWithContext = <
return ;
}
- /** FIXME: isReactionEnabled should be removed with next major version and a proper centralized permissions logic should be put in place
- * With the current permissions implementation it would be sth like:
- * const messageActions = getMessageActions();
- * const canReact = messageActions.includes(MESSAGE_ACTIONS.react);
- */
- const canReact = isReactionEnabled;
- const canShowReactions = hasReactions;
-
const showMetadata = !groupedByUser || endOfGroup;
const showReplyCountButton = !threadList && !!message.reply_count;
const allowRetry = message.status === 'failed' && message.errorStatusCode !== 403;
@@ -136,7 +122,7 @@ const MessageSimpleWithContext = <
'str-chat__message--has-attachment': hasAttachment,
'str-chat__message--highlighted': highlighted,
'str-chat__message--pinned pinned-message': message.pinned,
- 'str-chat__message--with-reactions': canShowReactions,
+ 'str-chat__message--with-reactions': hasReactions,
'str-chat__message-send-can-be-retried':
message?.status === 'failed' && message?.errorStatusCode !== 403,
'str-chat__message-with-thread-link': showReplyCountButton,
@@ -190,8 +176,7 @@ const MessageSimpleWithContext = <
>
- {canShowReactions && }
- {showDetailedReactions && canReact && }
+ {hasReactions && }
{message.attachments?.length && !message.quoted_message ? (
diff --git a/src/components/Message/__tests__/MessageOptions.test.js b/src/components/Message/__tests__/MessageOptions.test.js
index 8b9309291..f3b569c56 100644
--- a/src/components/Message/__tests__/MessageOptions.test.js
+++ b/src/components/Message/__tests__/MessageOptions.test.js
@@ -1,6 +1,6 @@
/* eslint-disable jest-dom/prefer-to-have-class */
import React from 'react';
-import { fireEvent, render } from '@testing-library/react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { Message } from '../Message';
@@ -22,6 +22,7 @@ import {
getTestClientWithUser,
} from '../../../mock-builders';
import { DialogsManagerProvider } from '../../../context';
+import { defaultReactionOptions } from '../../Reactions';
const MESSAGE_ACTIONS_TEST_ID = 'message-actions';
@@ -73,6 +74,7 @@ async function renderMessageOptions({
onReactionListClick={customMessageProps?.onReactionListClick}
/>
),
+ reactionOptions: defaultReactionOptions,
}}
>
@@ -182,6 +184,85 @@ describe(' ', () => {
expect(queryByTestId(reactionActionTestId)).not.toBeInTheDocument();
});
+ it('should not render ReactionsSelector until open', async () => {
+ const { queryByTestId } = await renderMessageOptions({
+ channelStateOpts: {
+ channelCapabilities: { 'send-reaction': true },
+ },
+ });
+ expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument();
+ await act(async () => {
+ await fireEvent.click(queryByTestId(reactionActionTestId));
+ });
+ expect(screen.getByTestId('reaction-selector')).toBeInTheDocument();
+ });
+
+ it('should unmount ReactionsSelector when closed by click on dialog overlay', async () => {
+ const { queryByTestId } = await renderMessageOptions({
+ channelStateOpts: {
+ channelCapabilities: { 'send-reaction': true },
+ },
+ });
+ await act(async () => {
+ await fireEvent.click(queryByTestId(reactionActionTestId));
+ });
+ await act(async () => {
+ await fireEvent.click(screen.getByTestId('str-chat__dialog-overlay'));
+ });
+ expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument();
+ });
+
+ it('should unmount ReactionsSelector when closed pressed Esc button', async () => {
+ const { queryByTestId } = await renderMessageOptions({
+ channelStateOpts: {
+ channelCapabilities: { 'send-reaction': true },
+ },
+ });
+ await act(async () => {
+ await fireEvent.click(queryByTestId(reactionActionTestId));
+ });
+ await act(async () => {
+ await fireEvent.keyUp(document, { charCode: 27, code: 'Escape', key: 'Escape' });
+ });
+ expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument();
+ });
+
+ it('should unmount ReactionsSelector when closed on reaction selection and closeReactionSelectorOnClick enabled', async () => {
+ const { queryByTestId } = await renderMessageOptions({
+ channelStateOpts: {
+ channelCapabilities: { 'send-reaction': true },
+ },
+ customMessageProps: {
+ closeReactionSelectorOnClick: true,
+ },
+ });
+ await act(async () => {
+ await fireEvent.click(queryByTestId(reactionActionTestId));
+ });
+ await act(async () => {
+ await fireEvent.click(screen.queryAllByTestId('select-reaction-button')[0]);
+ });
+ expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument();
+ });
+
+ it('should not unmount ReactionsSelector when closed on reaction selection and closeReactionSelectorOnClick enabled', async () => {
+ const { queryByTestId } = await renderMessageOptions({
+ channelStateOpts: {
+ channelCapabilities: { 'send-reaction': true },
+ },
+ customMessageProps: {
+ closeReactionSelectorOnClick: false,
+ },
+ });
+ await act(async () => {
+ await fireEvent.click(queryByTestId(reactionActionTestId));
+ });
+ await act(async () => {
+ await fireEvent.click(screen.queryAllByTestId('select-reaction-button')[0]);
+ });
+ expect(screen.queryByTestId('reaction-selector')).toBeInTheDocument();
+ });
+
it('should render message actions', async () => {
const { queryByTestId } = await renderMessageOptions({
channelStateOpts: { channelCapabilities: minimumCapabilitiesToRenderMessageActions },
diff --git a/src/components/Message/__tests__/QuotedMessage.test.js b/src/components/Message/__tests__/QuotedMessage.test.js
index 67c9cf6ae..b076be757 100644
--- a/src/components/Message/__tests__/QuotedMessage.test.js
+++ b/src/components/Message/__tests__/QuotedMessage.test.js
@@ -9,6 +9,7 @@ import {
ChannelStateProvider,
ChatProvider,
ComponentProvider,
+ DialogsManagerProvider,
TranslationProvider,
} from '../../../context';
import {
@@ -65,9 +66,11 @@ async function renderQuotedMessage(customProps) {
Message: () => ,
}}
>
-
-
-
+
+
+
+
+
diff --git a/src/components/Message/hooks/__tests__/useReactionHandler.test.js b/src/components/Message/hooks/__tests__/useReactionHandler.test.js
index 04a03f1c4..3b61291ff 100644
--- a/src/components/Message/hooks/__tests__/useReactionHandler.test.js
+++ b/src/components/Message/hooks/__tests__/useReactionHandler.test.js
@@ -1,11 +1,7 @@
import React from 'react';
-import { act, renderHook } from '@testing-library/react-hooks';
+import { renderHook } from '@testing-library/react-hooks';
-import {
- reactionHandlerWarning,
- useReactionClick,
- useReactionHandler,
-} from '../useReactionHandler';
+import { reactionHandlerWarning, useReactionHandler } from '../useReactionHandler';
import { ChannelActionProvider } from '../../../../context/ChannelActionContext';
import { ChannelStateProvider } from '../../../../context/ChannelStateContext';
@@ -123,192 +119,3 @@ describe('useReactionHandler custom hook', () => {
expect(updateMessage).toHaveBeenCalledWith(message);
});
});
-
-function renderUseReactionClickHook(
- message = generateMessage(),
- reactionListRef = React.createRef(),
- messageWrapperRef = React.createRef(),
-) {
- const channel = generateChannel();
-
- const wrapper = ({ children }) => (
-
- {children}
-
- );
-
- const { rerender, result } = renderHook(
- () => useReactionClick(message, reactionListRef, messageWrapperRef),
- { wrapper },
- );
- return { rerender, result };
-}
-
-describe('useReactionClick custom hook', () => {
- beforeEach(jest.clearAllMocks);
- it('should initialize a click handler and a flag for showing detailed reactions', () => {
- const {
- result: { current },
- } = renderUseReactionClickHook();
-
- expect(typeof current.onReactionListClick).toBe('function');
- expect(current.showDetailedReactions).toBe(false);
- });
-
- it('should set show details to true on click', async () => {
- const { result } = renderUseReactionClickHook();
- expect(result.current.showDetailedReactions).toBe(false);
- await act(() => {
- result.current.onReactionListClick();
- });
- expect(result.current.showDetailedReactions).toBe(true);
- });
-
- it('should return correct value for isReactionEnabled', () => {
- const channel = generateChannel();
- const channelCapabilities = { 'send-reaction': true };
-
- const { rerender, result } = renderHook(
- () => useReactionClick(generateMessage(), React.createRef(), React.createRef()),
- {
- // eslint-disable-next-line react/display-name
- wrapper: ({ children }) => (
-
- {children}
-
- ),
- },
- );
-
- expect(result.current.isReactionEnabled).toBe(true);
- channelCapabilities['send-reaction'] = false;
- rerender();
- expect(result.current.isReactionEnabled).toBe(false);
- channelCapabilities['send-reaction'] = true;
- rerender();
- expect(result.current.isReactionEnabled).toBe(true);
- });
-
- it('should set event listener to close reaction list on document click when list is opened', async () => {
- const clickMock = {
- target: document.createElement('div'),
- };
- const { result } = renderUseReactionClickHook();
- let onDocumentClick;
- const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation(
- jest.fn((_, fn) => {
- onDocumentClick = fn;
- }),
- );
- await act(() => {
- result.current.onReactionListClick();
- });
- expect(result.current.showDetailedReactions).toBe(true);
- expect(document.addEventListener).toHaveBeenCalledTimes(1);
- expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function));
- await act(() => {
- onDocumentClick(clickMock);
- });
- expect(result.current.showDetailedReactions).toBe(false);
- addEventListenerSpy.mockRestore();
- });
-
- it('should set event listener to message wrapper reference when one is set', async () => {
- const mockMessageWrapperReference = {
- current: {
- addEventListener: jest.fn(),
- removeEventListener: jest.fn(),
- },
- };
- const { result } = renderUseReactionClickHook(
- generateMessage(),
- React.createRef(),
- mockMessageWrapperReference,
- );
- await act(() => {
- result.current.onReactionListClick();
- });
- expect(mockMessageWrapperReference.current.addEventListener).toHaveBeenCalledWith(
- 'mouseleave',
- expect.any(Function),
- );
- });
-
- it('should not close reaction list on document click when click is on the reaction list itself', async () => {
- const message = generateMessage();
- const reactionSelectorEl = document.createElement('div');
- const reactionListElement = document.createElement('div').appendChild(reactionSelectorEl);
- const clickMock = {
- target: reactionSelectorEl,
- };
- const { result } = renderUseReactionClickHook(message, {
- current: reactionListElement,
- });
- let onDocumentClick;
- const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation(
- jest.fn((_, fn) => {
- onDocumentClick = fn;
- }),
- );
- await act(() => {
- result.current.onReactionListClick();
- });
- expect(result.current.showDetailedReactions).toBe(true);
- await act(() => {
- onDocumentClick(clickMock);
- });
- expect(result.current.showDetailedReactions).toBe(true);
- addEventListenerSpy.mockRestore();
- });
-
- it('should remove close click event listeners after reaction list is closed', async () => {
- const clickMock = {
- target: document.createElement('div'),
- };
- const { result } = renderUseReactionClickHook();
- let onDocumentClick;
- const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation(
- jest.fn((_, fn) => {
- onDocumentClick = fn;
- }),
- );
- const removeEventListenerSpy = jest
- .spyOn(document, 'removeEventListener')
- .mockImplementationOnce(jest.fn());
- await act(() => {
- result.current.onReactionListClick();
- });
- expect(result.current.showDetailedReactions).toBe(true);
- act(() => onDocumentClick(clickMock));
- expect(result.current.showDetailedReactions).toBe(false);
- expect(document.removeEventListener).toHaveBeenCalledWith('click', onDocumentClick);
- addEventListenerSpy.mockRestore();
- removeEventListenerSpy.mockRestore();
- });
-
- it('should remove close click event listeners if message is deleted', async () => {
- const clickMock = {
- target: document.createElement('div'),
- };
- const message = generateMessage();
- let onDocumentClick;
- const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation(
- jest.fn((_, fn) => {
- onDocumentClick = fn;
- }),
- );
- const removeEventListenerSpy = jest
- .spyOn(document, 'removeEventListener')
- .mockImplementationOnce(jest.fn());
- const { rerender, result } = renderUseReactionClickHook(message);
- expect(document.removeEventListener).not.toHaveBeenCalled();
- await act(() => {
- result.current.onReactionListClick(clickMock);
- });
- message.deleted_at = new Date();
- rerender();
- expect(document.removeEventListener).toHaveBeenCalledWith('click', onDocumentClick);
- addEventListenerSpy.mockRestore();
- removeEventListenerSpy.mockRestore();
- });
-});
diff --git a/src/components/Message/hooks/useReactionHandler.ts b/src/components/Message/hooks/useReactionHandler.ts
index b7275801d..2565d00bb 100644
--- a/src/components/Message/hooks/useReactionHandler.ts
+++ b/src/components/Message/hooks/useReactionHandler.ts
@@ -1,12 +1,10 @@
-import React, { RefObject, useCallback, useEffect, useRef, useState } from 'react';
+import React, { useCallback } from 'react';
import throttle from 'lodash.throttle';
import { useChannelActionContext } from '../../../context/ChannelActionContext';
import { StreamMessage, useChannelStateContext } from '../../../context/ChannelStateContext';
import { useChatContext } from '../../../context/ChatContext';
-import type { ReactEventHandler } from '../types';
-
import type { Reaction, ReactionResponse } from 'stream-chat';
import type { DefaultStreamChatGenerics } from '../../../types/types';
@@ -141,93 +139,3 @@ export const useReactionHandler = <
}
};
};
-
-export const useReactionClick = <
- StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
->(
- message?: StreamMessage,
- reactionSelectorRef?: RefObject,
- messageWrapperRef?: RefObject,
- closeReactionSelectorOnClick?: boolean,
-) => {
- const { channelCapabilities = {} } = useChannelStateContext(
- 'useReactionClick',
- );
-
- const [showDetailedReactions, setShowDetailedReactions] = useState(false);
-
- const hasListener = useRef(false);
-
- const isReactionEnabled = channelCapabilities['send-reaction'];
-
- const messageDeleted = !!message?.deleted_at;
-
- const closeDetailedReactions: EventListener = useCallback(
- (event) => {
- if (
- event.target instanceof HTMLElement &&
- reactionSelectorRef?.current?.contains(event.target) &&
- !closeReactionSelectorOnClick
- ) {
- return;
- }
-
- setShowDetailedReactions(false);
- },
- [closeReactionSelectorOnClick, setShowDetailedReactions, reactionSelectorRef],
- );
-
- useEffect(() => {
- const messageWrapper = messageWrapperRef?.current;
-
- if (showDetailedReactions && !hasListener.current) {
- hasListener.current = true;
- document.addEventListener('click', closeDetailedReactions);
- messageWrapper?.addEventListener('mouseleave', closeDetailedReactions);
- }
-
- if (!showDetailedReactions && hasListener.current) {
- document.removeEventListener('click', closeDetailedReactions);
- messageWrapper?.removeEventListener('mouseleave', closeDetailedReactions);
-
- hasListener.current = false;
- }
-
- return () => {
- if (hasListener.current) {
- document.removeEventListener('click', closeDetailedReactions);
-
- if (messageWrapper) {
- messageWrapper.removeEventListener('mouseleave', closeDetailedReactions);
- }
-
- hasListener.current = false;
- }
- };
- }, [showDetailedReactions, closeDetailedReactions, messageWrapperRef]);
-
- useEffect(() => {
- const messageWrapper = messageWrapperRef?.current;
-
- if (messageDeleted && hasListener.current) {
- document.removeEventListener('click', closeDetailedReactions);
-
- if (messageWrapper) {
- messageWrapper.removeEventListener('mouseleave', closeDetailedReactions);
- }
-
- hasListener.current = false;
- }
- }, [messageDeleted, closeDetailedReactions, messageWrapperRef]);
-
- const onReactionListClick: ReactEventHandler = (event) => {
- event?.stopPropagation?.();
- setShowDetailedReactions((prev) => !prev);
- };
-
- return {
- isReactionEnabled,
- onReactionListClick,
- showDetailedReactions,
- };
-};
diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx
index 9d659e4f8..26e6a8c23 100644
--- a/src/components/Message/utils.tsx
+++ b/src/components/Message/utils.tsx
@@ -314,6 +314,10 @@ export const areMessagePropsEqual = <
return false;
}
+ if (nextProps.closeReactionSelectorOnClick !== prevProps.closeReactionSelectorOnClick) {
+ return false;
+ }
+
const messagesAreEqual = areMessagesEqual(prevMessage, nextMessage);
if (!messagesAreEqual) return false;
diff --git a/src/components/Reactions/ReactionSelector.tsx b/src/components/Reactions/ReactionSelector.tsx
index d8fa2b53d..9f2d9475b 100644
--- a/src/components/Reactions/ReactionSelector.tsx
+++ b/src/components/Reactions/ReactionSelector.tsx
@@ -1,9 +1,8 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
-import { isMutableRef } from './utils/utils';
-
import { AvatarProps, Avatar as DefaultAvatar } from '../Avatar';
+import { useDialog } from '../Dialog';
import { useComponentContext } from '../../context/ComponentContext';
import { useMessageContext } from '../../context/MessageContext';
@@ -12,6 +11,7 @@ import type { ReactionGroupResponse, ReactionResponse } from 'stream-chat';
import type { DefaultStreamChatGenerics } from '../../types/types';
import type { ReactionOptions } from './reactionOptions';
+import { isMutableRef } from './utils/utils';
export type ReactionSelectorProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
@@ -39,181 +39,191 @@ export type ReactionSelectorProps<
reverse?: boolean;
};
-const UnMemoizedReactionSelector = React.forwardRef(
- (
- props: ReactionSelectorProps,
- ref: React.ForwardedRef,
- ) => {
- const {
- Avatar: propAvatar,
- detailedView = true,
- handleReaction: propHandleReaction,
- latest_reactions: propLatestReactions,
- own_reactions: propOwnReactions,
- reaction_groups: propReactionGroups,
- reactionOptions: propReactionOptions,
- reverse = false,
- } = props;
-
- const {
- Avatar: contextAvatar,
- reactionOptions: contextReactionOptions,
- } = useComponentContext('ReactionSelector');
- const {
- handleReaction: contextHandleReaction,
- message,
- } = useMessageContext('ReactionSelector');
-
- const reactionOptions = propReactionOptions ?? contextReactionOptions;
-
- const Avatar = propAvatar || contextAvatar || DefaultAvatar;
- const handleReaction = propHandleReaction || contextHandleReaction;
- const latestReactions = propLatestReactions || message?.latest_reactions || [];
- const ownReactions = propOwnReactions || message?.own_reactions || [];
- const reactionGroups = propReactionGroups || message?.reaction_groups || {};
-
- const [tooltipReactionType, setTooltipReactionType] = useState(null);
- const [tooltipPositions, setTooltipPositions] = useState<{
- arrow: number;
- tooltip: number;
- } | null>(null);
-
- const targetRef = useRef(null);
- const tooltipRef = useRef(null);
-
- const showTooltip = useCallback(
- (event: React.MouseEvent, reactionType: string) => {
- targetRef.current = event.currentTarget;
- setTooltipReactionType(reactionType);
- },
- [],
- );
-
- const hideTooltip = useCallback(() => {
- setTooltipReactionType(null);
- setTooltipPositions(null);
- }, []);
-
- useEffect(() => {
- if (tooltipReactionType) {
- const tooltip = tooltipRef.current?.getBoundingClientRect();
- const target = targetRef.current?.getBoundingClientRect();
-
- const container = isMutableRef(ref) ? ref.current?.getBoundingClientRect() : null;
-
- if (!tooltip || !target || !container) return;
-
- const tooltipPosition =
- tooltip.width === container.width || tooltip.x < container.x
- ? 0
- : target.left + target.width / 2 - container.left - tooltip.width / 2;
-
- const arrowPosition = target.x - tooltip.x + target.width / 2 - tooltipPosition;
-
- setTooltipPositions({
- arrow: arrowPosition,
- tooltip: tooltipPosition,
- });
- }
- }, [tooltipReactionType, ref]);
-
- const getUsersPerReactionType = (type: string | null) =>
- latestReactions
- .map((reaction) => {
- if (reaction.type === type) {
- return reaction.user?.name || reaction.user?.id;
- }
- return null;
- })
- .filter(Boolean);
-
- const iHaveReactedWithReaction = (reactionType: string) =>
- ownReactions.find((reaction) => reaction.type === reactionType);
-
- const getLatestUserForReactionType = (type: string | null) =>
- latestReactions.find((reaction) => reaction.type === type && !!reaction.user)?.user ||
- undefined;
-
- return (
- (
+ props: ReactionSelectorProps
,
+) => {
+ const {
+ Avatar: propAvatar,
+ detailedView = true,
+ handleReaction: propHandleReaction,
+ latest_reactions: propLatestReactions,
+ own_reactions: propOwnReactions,
+ reaction_groups: propReactionGroups,
+ reactionOptions: propReactionOptions,
+ reverse = false,
+ } = props;
+
+ const {
+ Avatar: contextAvatar,
+ reactionOptions: contextReactionOptions,
+ } = useComponentContext('ReactionSelector');
+ const {
+ closeReactionSelectorOnClick,
+ handleReaction: contextHandleReaction,
+ message,
+ } = useMessageContext('ReactionSelector');
+ const dialogId = `reaction-selector--${message.id}`;
+ const dialog = useDialog({ id: dialogId });
+ const reactionOptions = propReactionOptions ?? contextReactionOptions;
+
+ const Avatar = propAvatar || contextAvatar || DefaultAvatar;
+ const handleReaction = propHandleReaction || contextHandleReaction;
+ const latestReactions = propLatestReactions || message?.latest_reactions || [];
+ const ownReactions = propOwnReactions || message?.own_reactions || [];
+ const reactionGroups = propReactionGroups || message?.reaction_groups || {};
+
+ const [tooltipReactionType, setTooltipReactionType] = useState(null);
+ const [tooltipPositions, setTooltipPositions] = useState<{
+ arrow: number;
+ tooltip: number;
+ } | null>(null);
+
+ const rootRef = useRef(null);
+ const targetRef = useRef(null);
+ const tooltipRef = useRef(null);
+
+ const showTooltip = useCallback(
+ (event: React.MouseEvent, reactionType: string) => {
+ targetRef.current = event.currentTarget;
+ setTooltipReactionType(reactionType);
+ },
+ [],
+ );
+
+ const hideTooltip = useCallback(() => {
+ setTooltipReactionType(null);
+ setTooltipPositions(null);
+ }, []);
+
+ useEffect(() => {
+ if (!tooltipReactionType || !rootRef.current) return;
+ const tooltip = tooltipRef.current?.getBoundingClientRect();
+ const target = targetRef.current?.getBoundingClientRect();
+
+ const container = isMutableRef(rootRef) ? rootRef.current?.getBoundingClientRect() : null;
+
+ if (!tooltip || !target || !container) return;
+
+ const tooltipPosition =
+ tooltip.width === container.width || tooltip.x < container.x
+ ? 0
+ : target.left + target.width / 2 - container.left - tooltip.width / 2;
+
+ const arrowPosition = target.x - tooltip.x + target.width / 2 - tooltipPosition;
+
+ setTooltipPositions({
+ arrow: arrowPosition,
+ tooltip: tooltipPosition,
+ });
+ }, [tooltipReactionType, rootRef]);
+
+ const getUsersPerReactionType = (type: string | null) =>
+ latestReactions
+ .map((reaction) => {
+ if (reaction.type === type) {
+ return reaction.user?.name || reaction.user?.id;
+ }
+ return null;
+ })
+ .filter(Boolean);
+
+ const iHaveReactedWithReaction = (reactionType: string) =>
+ ownReactions.find((reaction) => reaction.type === reactionType);
+
+ const getLatestUserForReactionType = (type: string | null) =>
+ latestReactions.find((reaction) => reaction.type === type && !!reaction.user)?.user ||
+ undefined;
+
+ return (
+
- {!!tooltipReactionType && detailedView && (
-
-
- {getUsersPerReactionType(tooltipReactionType)?.map((user, i, users) => (
-
- {`${user}${i < users.length - 1 ? ', ' : ''}`}
-
- ))}
-
- )}
-
- {reactionOptions.map(({ Component, name: reactionName, type: reactionType }) => {
- const latestUser = getLatestUserForReactionType(reactionType);
- const count = reactionGroups[reactionType]?.count ?? 0;
- return (
-
- handleReaction(reactionType, event)}
- >
- {!!count && detailedView && (
- showTooltip(e, reactionType)}
- onMouseLeave={hideTooltip}
- >
- {latestUser ? (
-
- ) : (
-
- )}
-
- )}
-
-
+ },
+ )}
+ data-testid='reaction-selector'
+ ref={rootRef}
+ >
+ {!!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 (
+
+ {
+ handleReaction(reactionType, event);
+ if (closeReactionSelectorOnClick) {
+ dialog.close();
+ }
+ }}
+ >
+ {!!count && detailedView && (
+ showTooltip(e, reactionType)}
+ onMouseLeave={hideTooltip}
+ >
+ {latestUser ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+ {Boolean(count) && detailedView && (
+
+ {count || ''}
- {Boolean(count) && detailedView && (
-
- {count || ''}
-
- )}
-
-
- );
- })}
-
-
- );
- },
-);
+ )}
+
+
+ );
+ })}
+
+
+ );
+};
/**
* Component that allows a user to select a reaction.
diff --git a/src/components/Reactions/ReactionSelectorWithButton.tsx b/src/components/Reactions/ReactionSelectorWithButton.tsx
new file mode 100644
index 000000000..5a083e7d2
--- /dev/null
+++ b/src/components/Reactions/ReactionSelectorWithButton.tsx
@@ -0,0 +1,54 @@
+import React, { ElementRef, useRef } from 'react';
+import { ReactionSelector as DefaultReactionSelector } from './ReactionSelector';
+import { DialogAnchor, useDialog, useDialogIsOpen } from '../Dialog';
+import { useComponentContext, useMessageContext, useTranslationContext } from '../../context';
+import type { DefaultStreamChatGenerics } from '../../types';
+import type { IconProps } from '../../types/types';
+
+type ReactionSelectorWithButtonProps = {
+ /* Custom component rendering the icon used in a button invoking reactions selector for a given message. */
+ ReactionIcon: React.ComponentType;
+ /* Theme string to be added to CSS class names. */
+ theme: string;
+};
+
+/**
+ * Internal convenience component - not to be exported. It just groups the button and the dialog anchor and thus prevents
+ * cluttering the parent component.
+ */
+export const ReactionSelectorWithButton = <
+ StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
+>({
+ ReactionIcon,
+ theme,
+}: ReactionSelectorWithButtonProps) => {
+ const { t } = useTranslationContext('ReactionSelectorWithButton');
+ const { isMyMessage, message } = useMessageContext('MessageOptions');
+ const { ReactionSelector = DefaultReactionSelector } = useComponentContext('MessageOptions');
+ const buttonRef = useRef>(null);
+ const dialogId = `reaction-selector--${message.id}`;
+ const dialog = useDialog({ id: dialogId });
+ const dialogIsOpen = useDialogIsOpen(dialogId);
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/components/Reactions/__tests__/ReactionSelector.test.js b/src/components/Reactions/__tests__/ReactionSelector.test.js
index 500d9d9e4..0c159777e 100644
--- a/src/components/Reactions/__tests__/ReactionSelector.test.js
+++ b/src/components/Reactions/__tests__/ReactionSelector.test.js
@@ -13,8 +13,9 @@ import { Avatar as AvatarMock } from '../../Avatar';
import { ComponentProvider } from '../../../context/ComponentContext';
import { MessageProvider } from '../../../context/MessageContext';
+import { DialogsManagerProvider } from '../../../context';
-import { generateReaction, generateUser } from '../../../mock-builders';
+import { generateMessage, generateReaction, generateUser } from '../../../mock-builders';
jest.mock('../../Avatar', () => ({
Avatar: jest.fn(() =>
),
@@ -35,11 +36,13 @@ const handleReactionMock = jest.fn();
const renderComponent = (props) =>
render(
-
-
-
-
- ,
+
+
+
+
+
+
+ ,
);
describe('ReactionSelector', () => {
diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx
index 77ac4ed9b..7a602ad5b 100644
--- a/src/context/MessageContext.tsx
+++ b/src/context/MessageContext.tsx
@@ -70,10 +70,6 @@ export type MessageContextValue<
handleRetry: ChannelActionContextValue['retrySendMessage'];
/** Function that returns whether the Message belongs to the current user */
isMyMessage: () => boolean;
- /** @deprecated will be removed in the next major release.
- * Whether sending reactions is enabled for the active channel.
- */
- isReactionEnabled: boolean;
/** The message object */
message: StreamMessage;
/** Indicates whether a message has not been read yet or has been marked unread */
@@ -82,22 +78,18 @@ export type MessageContextValue<
onMentionsClickMessage: ReactEventHandler;
/** Handler function for a hover event on an @mention in Message */
onMentionsHoverMessage: ReactEventHandler;
- /** Handler function for a click event on the reaction list */
- onReactionListClick: ReactEventHandler;
/** Handler function for a click event on the user that posted the Message */
onUserClick: ReactEventHandler;
/** Handler function for a hover event on the user that posted the Message */
onUserHover: ReactEventHandler;
- /** Ref to be placed on the reaction selector component */
- reactionSelectorRef: React.MutableRefObject;
/** Function to toggle the edit state on a Message */
setEditingState: ReactEventHandler;
- /** Whether or not to show reaction list details */
- showDetailedReactions: boolean;
/** Additional props for underlying MessageInput component, [available props](https://getstream.io/chat/docs/sdk/react/message-input-components/message_input/#props) */
additionalMessageInputProps?: MessageInputProps;
/** Call this function to keep message list scrolled to the bottom when the scroll height increases, e.g. an element appears below the last message (only used in the `VirtualizedMessageList`) */
autoscrollToBottom?: () => void;
+ /** Message component configuration prop. If true, picking a reaction from the `ReactionSelector` component will close the selector */
+ closeReactionSelectorOnClick?: boolean;
/** Object containing custom message actions and function handlers */
customMessageActions?: CustomMessageActions;
/** If true, the message is the last one in a group sent by a specific user (only used in the `VirtualizedMessageList`) */
From 8d1c98a865d8be2d6102a16a7c806b7fc1b6dc0c Mon Sep 17 00:00:00 2001
From: martincupela
Date: Mon, 9 Sep 2024 16:03:47 +0200
Subject: [PATCH 15/29] fix: close MessageActionsBox on click inside
---
src/components/MessageActions/MessageActions.tsx | 11 ++++++++---
.../MessageActions/__tests__/MessageActions.test.js | 8 ++++----
2 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx
index 7ee4145c0..aa27f68d1 100644
--- a/src/components/MessageActions/MessageActions.tsx
+++ b/src/components/MessageActions/MessageActions.tsx
@@ -103,7 +103,11 @@ export const MessageActions = <
if (!renderMessageActions) return null;
return (
-
+
@@ -141,10 +144,11 @@ export const MessageActions = <
export type MessageActionsWrapperProps = {
customWrapperClass?: string;
inline?: boolean;
+ toggleOpen?: () => void;
};
const MessageActionsWrapper = (props: PropsWithChildren) => {
- const { children, customWrapperClass, inline } = props;
+ const { children, customWrapperClass, inline, toggleOpen } = props;
const defaultWrapperClass = clsx(
'str-chat__message-simple__actions__action',
@@ -155,6 +159,7 @@ const MessageActionsWrapper = (props: PropsWithChildren{children};
diff --git a/src/components/MessageActions/__tests__/MessageActions.test.js b/src/components/MessageActions/__tests__/MessageActions.test.js
index 13122e5e7..04677f656 100644
--- a/src/components/MessageActions/__tests__/MessageActions.test.js
+++ b/src/components/MessageActions/__tests__/MessageActions.test.js
@@ -46,7 +46,7 @@ const messageContextValue = {
const chatClient = getTestClient();
-function renderMessageActions(customProps, renderer = render) {
+function renderMessageActions(customProps = {}, renderer = render) {
return renderer(
@@ -83,6 +83,7 @@ describe(' component', () => {
component', () => {
aria-label="Open Message Actions Menu"
className="str-chat__message-actions-box-button"
data-testid="message-actions-toggle-button"
- onClick={[Function]}
>
component', () => {
component', () => {
aria-label="Open Message Actions Menu"
className="str-chat__message-actions-box-button"
data-testid="message-actions-toggle-button"
- onClick={[Function]}
>
component', () => {
component', () => {
aria-label="Open Message Actions Menu"
className="str-chat__message-actions-box-button"
data-testid="message-actions-toggle-button"
- onClick={[Function]}
>
Date: Mon, 9 Sep 2024 16:04:33 +0200
Subject: [PATCH 16/29] test: add DialogManager tests
---
src/components/Dialog/DialogsManager.ts | 56 ++-
.../Dialog/__tests__/DialogsManager.test.js | 347 ++++++++++++++++++
src/components/Dialog/hooks/useDialog.ts | 4 +-
3 files changed, 386 insertions(+), 21 deletions(-)
create mode 100644 src/components/Dialog/__tests__/DialogsManager.test.js
diff --git a/src/components/Dialog/DialogsManager.ts b/src/components/Dialog/DialogsManager.ts
index c15a7052d..becf6d39c 100644
--- a/src/components/Dialog/DialogsManager.ts
+++ b/src/components/Dialog/DialogsManager.ts
@@ -2,7 +2,6 @@ type DialogId = string;
export type GetOrCreateParams = {
id: DialogId;
- isOpen?: boolean;
};
export type Dialog = {
@@ -15,7 +14,7 @@ export type Dialog = {
toggleSingle: () => void;
};
-type DialogEvent = { type: 'close' | 'open' | 'openCountChange' };
+type DialogEvent = { type: 'close' | 'open' };
const dialogsManagerEvents = ['openCountChange'] as const;
type DialogsManagerEvent = { type: typeof dialogsManagerEvents[number] };
@@ -46,7 +45,7 @@ export class DialogsManager {
this.id = id ?? new Date().getTime().toString();
}
- getOrCreate({ id, isOpen = false }: GetOrCreateParams) {
+ getOrCreate({ id }: GetOrCreateParams) {
let dialog = this.dialogs[id];
if (!dialog) {
dialog = {
@@ -54,7 +53,7 @@ export class DialogsManager {
this.close(id);
},
id,
- isOpen,
+ isOpen: false,
open: () => {
this.open({ id });
},
@@ -88,10 +87,11 @@ export class DialogsManager {
if (!id) return noop;
if (!this.dialogEventListeners[id]) {
- this.dialogEventListeners[id] = { close: [], open: [] };
+ this.dialogEventListeners[id] = {};
}
- this.dialogEventListeners[id][eventType] = [
- ...(this.dialogEventListeners[id][eventType] ?? []),
+
+ this.dialogEventListeners[id][eventType as DialogEvent['type']] = [
+ ...(this.dialogEventListeners[id][eventType as DialogEvent['type']] ?? []),
listener as DialogEventHandler,
];
return () => {
@@ -104,18 +104,33 @@ export class DialogsManager {
{ id, listener }: { listener: DialogEventHandler | DialogsManagerEventHandler; id?: DialogId },
) {
if (dialogsManagerEvents.includes(eventType as DialogsManagerEvent['type'])) {
- const eventListeners = this.dialogsManagerEventListeners[
+ if (!this.dialogsManagerEventListeners[eventType as DialogsManagerEvent['type']]?.length)
+ return;
+
+ this.dialogsManagerEventListeners[
eventType as DialogsManagerEvent['type']
- ];
- eventListeners?.filter((l) => l !== listener);
+ ] = this.dialogsManagerEventListeners[eventType as DialogsManagerEvent['type']]?.filter(
+ (l) => l !== listener,
+ );
return;
}
if (!id) return;
- const eventListeners = this.dialogEventListeners[id]?.[eventType];
+ const eventListeners = this.dialogEventListeners[id]?.[eventType as DialogEvent['type']];
if (!eventListeners) return;
- this.dialogEventListeners[id][eventType] = eventListeners.filter((l) => l !== listener);
+
+ this.dialogEventListeners[id][eventType as DialogEvent['type']] = eventListeners.filter(
+ (l) => l !== listener,
+ );
+
+ if (!this.dialogEventListeners[id][eventType as DialogEvent['type']]?.length) {
+ delete this.dialogEventListeners[id][eventType as DialogEvent['type']];
+ }
+
+ if (!Object.keys(this.dialogEventListeners[id]).length) {
+ delete this.dialogEventListeners[id];
+ }
}
open(params: GetOrCreateParams, single?: boolean) {
@@ -127,7 +142,7 @@ export class DialogsManager {
this.dialogs[params.id].isOpen = true;
this.openDialogCount++;
this.dialogsManagerEventListeners.openCountChange.forEach((listener) => listener(this));
- this.dialogEventListeners[params.id].open?.forEach((listener) => listener(dialog));
+ this.dialogEventListeners[params.id]?.open?.forEach((listener) => listener(dialog));
}
close(id: DialogId) {
@@ -135,7 +150,7 @@ export class DialogsManager {
if (!dialog?.isOpen) return;
dialog.isOpen = false;
this.openDialogCount--;
- this.dialogEventListeners[id].close?.forEach((listener) => listener(dialog));
+ this.dialogEventListeners[id]?.close?.forEach((listener) => listener(dialog));
this.dialogsManagerEventListeners.openCountChange.forEach((listener) => listener(this));
}
@@ -144,7 +159,7 @@ export class DialogsManager {
}
toggleOpen(params: GetOrCreateParams) {
- if (this.dialogs[params.id].isOpen) {
+ if (this.dialogs[params.id]?.isOpen) {
this.close(params.id);
} else {
this.open(params);
@@ -152,7 +167,7 @@ export class DialogsManager {
}
toggleOpenSingle(params: GetOrCreateParams) {
- if (this.dialogs[params.id].isOpen) {
+ if (this.dialogs[params.id]?.isOpen) {
this.close(params.id);
} else {
this.open(params, true);
@@ -160,8 +175,8 @@ export class DialogsManager {
}
remove(id: DialogId) {
- const dialogs = { ...this.dialogs };
- if (!dialogs[id]) return;
+ const dialog = this.dialogs[id];
+ if (!dialog) return;
const countListeners =
!!this.dialogEventListeners[id] &&
@@ -172,7 +187,10 @@ export class DialogsManager {
if (!countListeners) {
delete this.dialogEventListeners[id];
- delete dialogs[id];
+ if (dialog.isOpen) {
+ this.openDialogCount--;
+ }
+ delete this.dialogs[id];
}
}
}
diff --git a/src/components/Dialog/__tests__/DialogsManager.test.js b/src/components/Dialog/__tests__/DialogsManager.test.js
new file mode 100644
index 000000000..dd7228ad1
--- /dev/null
+++ b/src/components/Dialog/__tests__/DialogsManager.test.js
@@ -0,0 +1,347 @@
+import { DialogsManager } from '../DialogsManager';
+
+const dialogId = 'dialogId';
+
+describe('DialogManager', () => {
+ it('initiates with provided options', () => {
+ const id = 'XX';
+ const dm = new DialogsManager({ 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();
+ expect(dm.id).toBe(mockedId);
+ spy.mockRestore();
+ });
+ it('creates a new closed dialog', () => {
+ const dm = new DialogsManager();
+ expect(Object.keys(dm.dialogs)).toHaveLength(0);
+ expect(dm.getOrCreate({ id: dialogId })).toMatchObject({
+ close: expect.any(Function),
+ id: 'dialogId',
+ isOpen: false,
+ open: expect.any(Function),
+ remove: expect.any(Function),
+ toggle: expect.any(Function),
+ toggleSingle: expect.any(Function),
+ });
+ expect(Object.keys(dm.dialogs)).toHaveLength(1);
+ expect(dm.openDialogCount).toBe(0);
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
+ expect(Object.keys(dm.dialogsManagerEventListeners)).toHaveLength(1);
+ expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0);
+ });
+
+ it('retrieves an existing dialog', () => {
+ const dm = new DialogsManager();
+ dm.dialogs[dialogId] = { id: dialogId, isOpen: true };
+ expect(dm.getOrCreate({ id: dialogId })).toMatchObject({
+ id: 'dialogId',
+ isOpen: true,
+ });
+ });
+
+ it('registers dialog event listener for non-existent dialog', () => {
+ const listener = jest.fn();
+ const dm = new DialogsManager();
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
+
+ dm.on('open', { id: dialogId, listener });
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(1);
+ expect(Object.keys(dm.dialogEventListeners[dialogId])).toHaveLength(1);
+ expect(dm.dialogEventListeners[dialogId].close).toBeUndefined();
+ expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1);
+ expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0);
+
+ dm.on('close', { id: dialogId, listener });
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(1);
+ expect(Object.keys(dm.dialogEventListeners[dialogId])).toHaveLength(2);
+ expect(dm.dialogEventListeners[dialogId].open).toHaveLength(1);
+ expect(dm.dialogEventListeners[dialogId].close).toHaveLength(1);
+ expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0);
+ });
+ it('registers dialog event listener for existing dialog', () => {
+ const listener = jest.fn();
+ const dm = new DialogsManager();
+ dm.getOrCreate({ id: dialogId });
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
+
+ dm.on('open', { id: dialogId, listener });
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(1);
+ expect(Object.keys(dm.dialogEventListeners[dialogId])).toHaveLength(1);
+ expect(dm.dialogEventListeners[dialogId].close).toBeUndefined();
+ expect(dm.dialogEventListeners[dialogId].open).toHaveLength(1);
+ expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0);
+
+ dm.on('close', { id: dialogId, listener });
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(1);
+ expect(Object.keys(dm.dialogEventListeners[dialogId])).toHaveLength(2);
+ expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1);
+ expect(Object.keys(dm.dialogEventListeners[dialogId].close)).toHaveLength(1);
+ expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0);
+ });
+
+ it('registers dialog manager event listener', () => {
+ const listener = jest.fn();
+ const dm = new DialogsManager();
+ expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0);
+
+ dm.on('openCountChange', { listener });
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
+ expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(1);
+ });
+
+ it('does not register dialog event listener without dialog id', () => {
+ const listener = jest.fn();
+ const dm = new DialogsManager();
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
+
+ dm.on('open', { listener });
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
+
+ dm.on('close', { listener });
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
+ });
+
+ it('unregisters dialog event listener for non-existent dialog', () => {
+ const listener1 = jest.fn();
+ const listener2 = jest.fn();
+ const dm = new DialogsManager();
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
+
+ dm.on('open', { id: dialogId, listener: listener1 });
+ dm.on('open', { id: dialogId, listener: listener2 });
+ expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(2);
+ dm.off('open', { id: dialogId, listener: listener1 });
+ expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1);
+
+ const unsubscribe = dm.on('open', { id: dialogId, listener: listener1 });
+ expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(2);
+ unsubscribe();
+ expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1);
+ dm.off('open', { id: dialogId, listener: listener2 });
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
+ });
+
+ it('unregisters dialog event listener for existing dialog', () => {
+ const listener1 = jest.fn();
+ const listener2 = jest.fn();
+ const dm = new DialogsManager();
+ dm.getOrCreate({ id: dialogId });
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
+
+ dm.on('open', { id: dialogId, listener: listener1 });
+ dm.on('open', { id: dialogId, listener: listener2 });
+ expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(2);
+ dm.off('open', { id: dialogId, listener: listener1 });
+ expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1);
+
+ const unsubscribe = dm.on('open', { id: dialogId, listener: listener1 });
+ expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(2);
+ unsubscribe();
+ expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1);
+ dm.off('open', { id: dialogId, listener: listener2 });
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
+ });
+
+ it('does not unregister dialog event listener without dialog id', () => {
+ const listener = jest.fn();
+ const dm = new DialogsManager();
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
+
+ dm.on('open', { id: dialogId, listener });
+ dm.off('open', { listener });
+ expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1);
+ });
+
+ it('unregisters dialog manager event listener', () => {
+ const listener1 = jest.fn();
+ const listener2 = jest.fn();
+ const dm = new DialogsManager();
+ dm.getOrCreate({ id: dialogId });
+ expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0);
+
+ const unsubscribe = dm.on('openCountChange', { listener: listener1 });
+ dm.on('openCountChange', { listener: listener2 });
+ expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(2);
+ dm.off('openCountChange', { listener: listener2 });
+ expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(1);
+ unsubscribe();
+ expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0);
+ });
+
+ it('executes all the dialog event listeners', () => {
+ const listener1 = jest.fn();
+ const listener2 = jest.fn();
+ const dm = new DialogsManager();
+ dm.on('open', { id: dialogId, listener: listener1 });
+ dm.on('close', { id: dialogId, listener: listener2 });
+ dm.open({ id: dialogId });
+ dm.close(dialogId);
+ expect(listener1).toHaveBeenCalledWith(dm.dialogs[dialogId]);
+ expect(listener2).toHaveBeenCalledWith(dm.dialogs[dialogId]);
+ });
+
+ it('executes all the dialog manager event listeners', () => {
+ const listener1 = jest.fn();
+ const listener2 = jest.fn();
+ const dm = new DialogsManager();
+ dm.on('openCountChange', { listener: listener1 });
+ dm.on('openCountChange', { listener: listener2 });
+ dm.open({ id: dialogId });
+ expect(listener1).toHaveBeenCalledWith(dm);
+ expect(listener2).toHaveBeenCalledWith(dm);
+ });
+
+ it('creates a dialog if it does not exist on open', () => {
+ const dm = new DialogsManager();
+ dm.open({ id: dialogId });
+ expect(dm.dialogs[dialogId]).toMatchObject({
+ close: expect.any(Function),
+ id: 'dialogId',
+ isOpen: true,
+ open: expect.any(Function),
+ remove: expect.any(Function),
+ toggle: expect.any(Function),
+ toggleSingle: expect.any(Function),
+ });
+ expect(dm.openDialogCount).toBe(1);
+ });
+
+ it('opens existing dialog', () => {
+ const dm = new DialogsManager();
+ dm.getOrCreate({ id: dialogId });
+ dm.open({ id: dialogId });
+ expect(dm.dialogs[dialogId].isOpen).toBeTruthy();
+ expect(dm.openDialogCount).toBe(1);
+ });
+
+ it('does not open already open dialog', () => {
+ const dm = new DialogsManager();
+ dm.getOrCreate({ id: dialogId });
+ dm.open({ id: dialogId });
+ dm.open({ id: dialogId });
+ expect(dm.openDialogCount).toBe(1);
+ });
+
+ it('closes all other dialogs before opening the target', () => {
+ const dm = new DialogsManager();
+ dm.open({ id: 'xxx' });
+ dm.open({ id: 'yyy' });
+ expect(dm.openDialogCount).toBe(2);
+ dm.open({ id: dialogId }, true);
+ expect(dm.dialogs.xxx.isOpen).toBeFalsy();
+ expect(dm.dialogs.yyy.isOpen).toBeFalsy();
+ expect(dm.dialogs[dialogId].isOpen).toBeTruthy();
+ expect(dm.openDialogCount).toBe(1);
+ });
+
+ it('closes opened dialog', () => {
+ const dm = new DialogsManager();
+ dm.open({ id: dialogId });
+ dm.close(dialogId);
+ expect(dm.dialogs[dialogId].isOpen).toBeFalsy();
+ expect(dm.openDialogCount).toBe(0);
+ });
+
+ it('does not close non-existent dialog', () => {
+ const listener = jest.fn();
+ const dm = new DialogsManager();
+ dm.open({ id: 'xxx' });
+ dm.on('close', { id: dialogId, listener });
+ dm.close(dialogId);
+ expect(listener).not.toHaveBeenCalled();
+ expect(dm.openDialogCount).toBe(1);
+ });
+
+ it('does not close already closed dialog', () => {
+ const listener = jest.fn();
+ const dm = new DialogsManager();
+ dm.open({ id: 'xxx' });
+ dm.open({ id: dialogId });
+ dm.on('close', { id: dialogId, listener });
+ dm.close(dialogId);
+ dm.close(dialogId);
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(dm.openDialogCount).toBe(1);
+ });
+
+ it('toggles the open state of a dialog', () => {
+ const openListener = jest.fn();
+ const closeListener = jest.fn();
+ const dm = new DialogsManager();
+ dm.on('open', { id: dialogId, listener: openListener });
+ dm.on('close', { id: dialogId, listener: closeListener });
+
+ dm.open({ id: 'xxx' });
+ dm.open({ id: 'yyy' });
+ dm.toggleOpen({ id: dialogId });
+ expect(openListener).toHaveBeenCalledTimes(1);
+ expect(closeListener).toHaveBeenCalledTimes(0);
+ expect(dm.openDialogCount).toBe(3);
+
+ dm.toggleOpen({ id: dialogId });
+ expect(openListener).toHaveBeenCalledTimes(1);
+ expect(closeListener).toHaveBeenCalledTimes(1);
+ expect(dm.openDialogCount).toBe(2);
+ });
+
+ it('keeps single opened dialog when the toggling open dialog state', () => {
+ const openListener = jest.fn();
+ const closeListener = jest.fn();
+ const dm = new DialogsManager();
+ dm.on('open', { id: dialogId, listener: openListener });
+ dm.on('close', { id: dialogId, listener: closeListener });
+
+ dm.open({ id: 'xxx' });
+ dm.open({ id: 'yyy' });
+ dm.toggleOpenSingle({ id: dialogId });
+ expect(openListener).toHaveBeenCalledTimes(1);
+ expect(closeListener).toHaveBeenCalledTimes(0);
+ expect(dm.openDialogCount).toBe(1);
+
+ dm.toggleOpenSingle({ id: dialogId });
+ expect(openListener).toHaveBeenCalledTimes(1);
+ expect(closeListener).toHaveBeenCalledTimes(1);
+ expect(dm.openDialogCount).toBe(0);
+ });
+
+ it('removes a dialog if no associated dialog event listeners', () => {
+ const openListener = jest.fn();
+ const dm = new DialogsManager();
+ dm.getOrCreate({ id: dialogId });
+ dm.on('open', { id: dialogId, listener: openListener });
+ dm.open({ id: dialogId });
+ dm.off('open', { id: dialogId, listener: openListener });
+ dm.remove(dialogId);
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
+ expect(dm.openDialogCount).toBe(0);
+ expect(Object.keys(dm.dialogs)).toHaveLength(0);
+ });
+
+ it('does not remove a dialog if associated dialog event listeners', () => {
+ const openListener = jest.fn();
+ const dm = new DialogsManager();
+ dm.getOrCreate({ id: dialogId });
+ dm.on('open', { id: dialogId, listener: openListener });
+ dm.open({ id: dialogId });
+ dm.remove(dialogId);
+ expect(Object.keys(dm.dialogEventListeners[dialogId])).toHaveLength(1);
+ expect(dm.openDialogCount).toBe(1);
+ expect(Object.keys(dm.dialogs)).toHaveLength(1);
+ });
+
+ it('handles attempt to remove non-existent dialog', () => {
+ const openListener = jest.fn();
+ const dm = new DialogsManager();
+ dm.getOrCreate({ id: dialogId });
+ dm.on('open', { id: dialogId, listener: openListener });
+ dm.open({ id: dialogId });
+ dm.remove('xxx');
+ expect(Object.keys(dm.dialogEventListeners)).toHaveLength(1);
+ expect(dm.openDialogCount).toBe(1);
+ expect(Object.keys(dm.dialogs)).toHaveLength(1);
+ });
+});
diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts
index 802fe5761..b98178346 100644
--- a/src/components/Dialog/hooks/useDialog.ts
+++ b/src/components/Dialog/hooks/useDialog.ts
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useDialogsManager } from '../../../context/DialogsManagerContext';
import type { GetOrCreateParams } from '../DialogsManager';
-export const useDialog = ({ id, isOpen }: GetOrCreateParams) => {
+export const useDialog = ({ id }: GetOrCreateParams) => {
const { dialogsManager } = useDialogsManager();
useEffect(
@@ -12,7 +12,7 @@ export const useDialog = ({ id, isOpen }: GetOrCreateParams) => {
[dialogsManager, id],
);
- return dialogsManager.getOrCreate({ id, isOpen });
+ return dialogsManager.getOrCreate({ id });
};
export const useDialogIsOpen = (id: string, source?: string) => {
From 9d1ee9d089def5350d8e3332c282ff1207bdb470 Mon Sep 17 00:00:00 2001
From: martincupela
Date: Mon, 9 Sep 2024 16:05:09 +0200
Subject: [PATCH 17/29] refactor: unmound popper element if closed in
useDialogAnchor
---
src/components/Dialog/DialogAnchor.tsx | 11 ++++-------
1 file changed, 4 insertions(+), 7 deletions(-)
diff --git a/src/components/Dialog/DialogAnchor.tsx b/src/components/Dialog/DialogAnchor.tsx
index db27d8ee6..5e411ba0f 100644
--- a/src/components/Dialog/DialogAnchor.tsx
+++ b/src/components/Dialog/DialogAnchor.tsx
@@ -42,6 +42,10 @@ export function useDialogAnchor({
}
}, [open, popperElement, update]);
+ if (popperElement && !open) {
+ setPopperElement(null);
+ }
+
return {
attributes,
setPopperElement,
@@ -87,13 +91,6 @@ export const DialogAnchor = ({
};
}, [dialog, open]);
- 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]);
-
// prevent rendering the dialog contents if the dialog should not be open / shown
if (!open) {
return null;
From d1e4df4d17f619ab723263778da967b7f17c48cb Mon Sep 17 00:00:00 2001
From: martincupela
Date: Tue, 10 Sep 2024 18:13:37 +0200
Subject: [PATCH 18/29] refactor: use StateStore to handle DialogsManager
subscriptions
---
src/components/Dialog/DialogPortal.tsx | 15 +-
src/components/Dialog/DialogsManager.ts | 151 +++-------
.../Dialog/__tests__/DialogsManager.test.js | 262 +++---------------
src/components/Dialog/hooks/useDialog.ts | 19 +-
.../__tests__/MessageActions.test.js | 12 +-
.../VirtualizedMessageList.test.js.snap | 22 +-
6 files changed, 112 insertions(+), 369 deletions(-)
diff --git a/src/components/Dialog/DialogPortal.tsx b/src/components/Dialog/DialogPortal.tsx
index e31a3582f..a01ced9d6 100644
--- a/src/components/Dialog/DialogPortal.tsx
+++ b/src/components/Dialog/DialogPortal.tsx
@@ -1,19 +1,22 @@
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);
+ const [shouldRender, setShouldRender] = useState(
+ !!dialogsManager.state.getLatestValue().openDialogCount,
+ );
+
useEffect(
() =>
- dialogsManager.on('openCountChange', {
- listener: (dm: DialogsManager) => {
- setShouldRender(dm.openDialogCount > 0);
+ dialogsManager.state.subscribeWithSelector(
+ ({ openDialogCount }) => [openDialogCount],
+ ([openDialogCount]) => {
+ setShouldRender(openDialogCount > 0);
},
- }),
+ ),
[dialogsManager],
);
diff --git a/src/components/Dialog/DialogsManager.ts b/src/components/Dialog/DialogsManager.ts
index becf6d39c..275f9f5ed 100644
--- a/src/components/Dialog/DialogsManager.ts
+++ b/src/components/Dialog/DialogsManager.ts
@@ -1,3 +1,5 @@
+import { StateStore } from 'stream-chat';
+
type DialogId = string;
export type GetOrCreateParams = {
@@ -14,39 +16,30 @@ export type Dialog = {
toggleSingle: () => void;
};
-type DialogEvent = { type: 'close' | 'open' };
-
-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;
+type Dialogs = Record;
+
+type DialogsManagerState = {
+ dialogs: Dialogs;
+ openDialogCount: number;
+};
export class DialogsManager {
id: string;
- openDialogCount = 0;
- dialogs: Record = {};
- private dialogEventListeners: Record<
- DialogId,
- Partial>
- > = {};
- private dialogsManagerEventListeners: Record<
- DialogsManagerEvent['type'],
- DialogsManagerEventHandler[]
- > = { openCountChange: [] };
+ state = new StateStore({
+ dialogs: {},
+ openDialogCount: 0,
+ });
constructor({ id }: DialogInitOptions = {}) {
this.id = id ?? new Date().getTime().toString();
}
getOrCreate({ id }: GetOrCreateParams) {
- let dialog = this.dialogs[id];
+ let dialog = this.state.getLatestValue().dialogs[id];
if (!dialog) {
dialog = {
close: () => {
@@ -67,99 +60,44 @@ export class DialogsManager {
this.toggleOpenSingle({ id });
},
};
- this.dialogs[id] = dialog;
+ this.state.next((current) => ({
+ ...current,
+ ...{ dialogs: { ...current.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] = {};
- }
-
- this.dialogEventListeners[id][eventType as DialogEvent['type']] = [
- ...(this.dialogEventListeners[id][eventType as DialogEvent['type']] ?? []),
- 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'])) {
- if (!this.dialogsManagerEventListeners[eventType as DialogsManagerEvent['type']]?.length)
- return;
-
- this.dialogsManagerEventListeners[
- eventType as DialogsManagerEvent['type']
- ] = this.dialogsManagerEventListeners[eventType as DialogsManagerEvent['type']]?.filter(
- (l) => l !== listener,
- );
- return;
- }
-
- if (!id) return;
-
- const eventListeners = this.dialogEventListeners[id]?.[eventType as DialogEvent['type']];
- if (!eventListeners) return;
-
- this.dialogEventListeners[id][eventType as DialogEvent['type']] = eventListeners.filter(
- (l) => l !== listener,
- );
-
- if (!this.dialogEventListeners[id][eventType as DialogEvent['type']]?.length) {
- delete this.dialogEventListeners[id][eventType as DialogEvent['type']];
- }
-
- if (!Object.keys(this.dialogEventListeners[id]).length) {
- delete this.dialogEventListeners[id];
- }
- }
-
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));
+ this.state.next((current) => ({
+ ...current,
+ dialogs: { ...current.dialogs, [dialog.id]: { ...dialog, isOpen: true } },
+ openDialogCount: ++current.openDialogCount,
+ }));
}
close(id: DialogId) {
- const dialog = this.dialogs[id];
+ const dialog = this.state.getLatestValue().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));
+ this.state.next((current) => ({
+ ...current,
+ dialogs: { ...current.dialogs, [dialog.id]: { ...dialog, isOpen: false } },
+ openDialogCount: --current.openDialogCount,
+ }));
}
closeAll() {
- Object.values(this.dialogs).forEach((dialog) => dialog.close());
+ Object.values(this.state.getLatestValue().dialogs).forEach((dialog) => dialog.close());
}
toggleOpen(params: GetOrCreateParams) {
- if (this.dialogs[params.id]?.isOpen) {
+ if (this.state.getLatestValue().dialogs[params.id]?.isOpen) {
this.close(params.id);
} else {
this.open(params);
@@ -167,7 +105,7 @@ export class DialogsManager {
}
toggleOpenSingle(params: GetOrCreateParams) {
- if (this.dialogs[params.id]?.isOpen) {
+ if (this.state.getLatestValue().dialogs[params.id]?.isOpen) {
this.close(params.id);
} else {
this.open(params, true);
@@ -175,22 +113,21 @@ export class DialogsManager {
}
remove(id: DialogId) {
- const dialog = this.dialogs[id];
+ const state = this.state.getLatestValue();
+ const dialog = state.dialogs[id];
if (!dialog) return;
- const countListeners =
- !!this.dialogEventListeners[id] &&
- Object.values(this.dialogEventListeners[id]).reduce((acc, listeners) => {
- acc += listeners.length;
+ this.state.next((current) => ({
+ ...current,
+ dialogs: Object.entries(current.dialogs).reduce((acc, [dialogId, dialog]) => {
+ if (id !== dialogId) {
+ acc[id] = dialog;
+ }
return acc;
- }, 0);
-
- if (!countListeners) {
- delete this.dialogEventListeners[id];
- if (dialog.isOpen) {
- this.openDialogCount--;
- }
- delete this.dialogs[id];
- }
+ }, {}),
+ 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 dd7228ad1..cc2c999f8 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 DialogsManager();
- expect(Object.keys(dm.dialogs)).toHaveLength(0);
+ expect(Object.keys(dm.state.getLatestValue().dialogs)).toHaveLength(0);
expect(dm.getOrCreate({ id: dialogId })).toMatchObject({
close: expect.any(Function),
id: 'dialogId',
@@ -27,178 +27,27 @@ describe('DialogManager', () => {
toggle: expect.any(Function),
toggleSingle: expect.any(Function),
});
- expect(Object.keys(dm.dialogs)).toHaveLength(1);
- expect(dm.openDialogCount).toBe(0);
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
- expect(Object.keys(dm.dialogsManagerEventListeners)).toHaveLength(1);
- expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0);
+ expect(Object.keys(dm.state.getLatestValue().dialogs)).toHaveLength(1);
+ expect(dm.state.getLatestValue().openDialogCount).toBe(0);
});
it('retrieves an existing dialog', () => {
const dm = new DialogsManager();
- dm.dialogs[dialogId] = { id: dialogId, isOpen: true };
+ dm.state.next((current) => ({
+ ...current,
+ dialogs: { ...current.dialogs, [dialogId]: { id: dialogId, isOpen: true } },
+ }));
expect(dm.getOrCreate({ id: dialogId })).toMatchObject({
id: 'dialogId',
isOpen: true,
});
- });
-
- it('registers dialog event listener for non-existent dialog', () => {
- const listener = jest.fn();
- const dm = new DialogsManager();
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
-
- dm.on('open', { id: dialogId, listener });
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(1);
- expect(Object.keys(dm.dialogEventListeners[dialogId])).toHaveLength(1);
- expect(dm.dialogEventListeners[dialogId].close).toBeUndefined();
- expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1);
- expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0);
-
- dm.on('close', { id: dialogId, listener });
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(1);
- expect(Object.keys(dm.dialogEventListeners[dialogId])).toHaveLength(2);
- expect(dm.dialogEventListeners[dialogId].open).toHaveLength(1);
- expect(dm.dialogEventListeners[dialogId].close).toHaveLength(1);
- expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0);
- });
- it('registers dialog event listener for existing dialog', () => {
- const listener = jest.fn();
- const dm = new DialogsManager();
- dm.getOrCreate({ id: dialogId });
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
-
- dm.on('open', { id: dialogId, listener });
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(1);
- expect(Object.keys(dm.dialogEventListeners[dialogId])).toHaveLength(1);
- expect(dm.dialogEventListeners[dialogId].close).toBeUndefined();
- expect(dm.dialogEventListeners[dialogId].open).toHaveLength(1);
- expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0);
-
- dm.on('close', { id: dialogId, listener });
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(1);
- expect(Object.keys(dm.dialogEventListeners[dialogId])).toHaveLength(2);
- expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1);
- expect(Object.keys(dm.dialogEventListeners[dialogId].close)).toHaveLength(1);
- expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0);
- });
-
- it('registers dialog manager event listener', () => {
- const listener = jest.fn();
- const dm = new DialogsManager();
- expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0);
-
- dm.on('openCountChange', { listener });
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
- expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(1);
- });
-
- it('does not register dialog event listener without dialog id', () => {
- const listener = jest.fn();
- const dm = new DialogsManager();
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
-
- dm.on('open', { listener });
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
-
- dm.on('close', { listener });
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
- });
-
- it('unregisters dialog event listener for non-existent dialog', () => {
- const listener1 = jest.fn();
- const listener2 = jest.fn();
- const dm = new DialogsManager();
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
-
- dm.on('open', { id: dialogId, listener: listener1 });
- dm.on('open', { id: dialogId, listener: listener2 });
- expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(2);
- dm.off('open', { id: dialogId, listener: listener1 });
- expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1);
-
- const unsubscribe = dm.on('open', { id: dialogId, listener: listener1 });
- expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(2);
- unsubscribe();
- expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1);
- dm.off('open', { id: dialogId, listener: listener2 });
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
- });
-
- it('unregisters dialog event listener for existing dialog', () => {
- const listener1 = jest.fn();
- const listener2 = jest.fn();
- const dm = new DialogsManager();
- dm.getOrCreate({ id: dialogId });
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
-
- dm.on('open', { id: dialogId, listener: listener1 });
- dm.on('open', { id: dialogId, listener: listener2 });
- expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(2);
- dm.off('open', { id: dialogId, listener: listener1 });
- expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1);
-
- const unsubscribe = dm.on('open', { id: dialogId, listener: listener1 });
- expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(2);
- unsubscribe();
- expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1);
- dm.off('open', { id: dialogId, listener: listener2 });
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
- });
-
- it('does not unregister dialog event listener without dialog id', () => {
- const listener = jest.fn();
- const dm = new DialogsManager();
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
-
- dm.on('open', { id: dialogId, listener });
- dm.off('open', { listener });
- expect(Object.keys(dm.dialogEventListeners[dialogId].open)).toHaveLength(1);
- });
-
- it('unregisters dialog manager event listener', () => {
- const listener1 = jest.fn();
- const listener2 = jest.fn();
- const dm = new DialogsManager();
- dm.getOrCreate({ id: dialogId });
- expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0);
-
- const unsubscribe = dm.on('openCountChange', { listener: listener1 });
- dm.on('openCountChange', { listener: listener2 });
- expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(2);
- dm.off('openCountChange', { listener: listener2 });
- expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(1);
- unsubscribe();
- expect(dm.dialogsManagerEventListeners.openCountChange).toHaveLength(0);
- });
-
- it('executes all the dialog event listeners', () => {
- const listener1 = jest.fn();
- const listener2 = jest.fn();
- const dm = new DialogsManager();
- dm.on('open', { id: dialogId, listener: listener1 });
- dm.on('close', { id: dialogId, listener: listener2 });
- dm.open({ id: dialogId });
- dm.close(dialogId);
- expect(listener1).toHaveBeenCalledWith(dm.dialogs[dialogId]);
- expect(listener2).toHaveBeenCalledWith(dm.dialogs[dialogId]);
- });
-
- it('executes all the dialog manager event listeners', () => {
- const listener1 = jest.fn();
- const listener2 = jest.fn();
- const dm = new DialogsManager();
- dm.on('openCountChange', { listener: listener1 });
- dm.on('openCountChange', { listener: listener2 });
- dm.open({ id: dialogId });
- expect(listener1).toHaveBeenCalledWith(dm);
- expect(listener2).toHaveBeenCalledWith(dm);
+ expect(Object.keys(dm.state.getLatestValue().dialogs)).toHaveLength(1);
});
it('creates a dialog if it does not exist on open', () => {
const dm = new DialogsManager();
dm.open({ id: dialogId });
- expect(dm.dialogs[dialogId]).toMatchObject({
+ expect(dm.state.getLatestValue().dialogs[dialogId]).toMatchObject({
close: expect.any(Function),
id: 'dialogId',
isOpen: true,
@@ -207,15 +56,15 @@ describe('DialogManager', () => {
toggle: expect.any(Function),
toggleSingle: expect.any(Function),
});
- expect(dm.openDialogCount).toBe(1);
+ expect(dm.state.getLatestValue().openDialogCount).toBe(1);
});
it('opens existing dialog', () => {
const dm = new DialogsManager();
dm.getOrCreate({ id: dialogId });
dm.open({ id: dialogId });
- expect(dm.dialogs[dialogId].isOpen).toBeTruthy();
- expect(dm.openDialogCount).toBe(1);
+ expect(dm.state.getLatestValue().dialogs[dialogId].isOpen).toBeTruthy();
+ expect(dm.state.getLatestValue().openDialogCount).toBe(1);
});
it('does not open already open dialog', () => {
@@ -223,125 +72,76 @@ describe('DialogManager', () => {
dm.getOrCreate({ id: dialogId });
dm.open({ id: dialogId });
dm.open({ id: dialogId });
- expect(dm.openDialogCount).toBe(1);
+ expect(dm.state.getLatestValue().openDialogCount).toBe(1);
});
it('closes all other dialogs before opening the target', () => {
const dm = new DialogsManager();
dm.open({ id: 'xxx' });
dm.open({ id: 'yyy' });
- expect(dm.openDialogCount).toBe(2);
+ expect(dm.state.getLatestValue().openDialogCount).toBe(2);
dm.open({ id: dialogId }, true);
- expect(dm.dialogs.xxx.isOpen).toBeFalsy();
- expect(dm.dialogs.yyy.isOpen).toBeFalsy();
- expect(dm.dialogs[dialogId].isOpen).toBeTruthy();
- expect(dm.openDialogCount).toBe(1);
+ const dialogs = dm.state.getLatestValue().dialogs;
+ expect(dialogs.xxx.isOpen).toBeFalsy();
+ expect(dialogs.yyy.isOpen).toBeFalsy();
+ expect(dm.state.getLatestValue().dialogs[dialogId].isOpen).toBeTruthy();
+ expect(dm.state.getLatestValue().openDialogCount).toBe(1);
});
it('closes opened dialog', () => {
const dm = new DialogsManager();
dm.open({ id: dialogId });
dm.close(dialogId);
- expect(dm.dialogs[dialogId].isOpen).toBeFalsy();
- expect(dm.openDialogCount).toBe(0);
- });
-
- it('does not close non-existent dialog', () => {
- const listener = jest.fn();
- const dm = new DialogsManager();
- dm.open({ id: 'xxx' });
- dm.on('close', { id: dialogId, listener });
- dm.close(dialogId);
- expect(listener).not.toHaveBeenCalled();
- expect(dm.openDialogCount).toBe(1);
+ expect(dm.state.getLatestValue().dialogs[dialogId].isOpen).toBeFalsy();
+ expect(dm.state.getLatestValue().openDialogCount).toBe(0);
});
it('does not close already closed dialog', () => {
- const listener = jest.fn();
const dm = new DialogsManager();
dm.open({ id: 'xxx' });
dm.open({ id: dialogId });
- dm.on('close', { id: dialogId, listener });
dm.close(dialogId);
dm.close(dialogId);
- expect(listener).toHaveBeenCalledTimes(1);
- expect(dm.openDialogCount).toBe(1);
+ expect(dm.state.getLatestValue().openDialogCount).toBe(1);
});
it('toggles the open state of a dialog', () => {
- const openListener = jest.fn();
- const closeListener = jest.fn();
const dm = new DialogsManager();
- dm.on('open', { id: dialogId, listener: openListener });
- dm.on('close', { id: dialogId, listener: closeListener });
-
dm.open({ id: 'xxx' });
dm.open({ id: 'yyy' });
dm.toggleOpen({ id: dialogId });
- expect(openListener).toHaveBeenCalledTimes(1);
- expect(closeListener).toHaveBeenCalledTimes(0);
- expect(dm.openDialogCount).toBe(3);
-
+ expect(dm.state.getLatestValue().openDialogCount).toBe(3);
dm.toggleOpen({ id: dialogId });
- expect(openListener).toHaveBeenCalledTimes(1);
- expect(closeListener).toHaveBeenCalledTimes(1);
- expect(dm.openDialogCount).toBe(2);
+ expect(dm.state.getLatestValue().openDialogCount).toBe(2);
});
it('keeps single opened dialog when the toggling open dialog state', () => {
- const openListener = jest.fn();
- const closeListener = jest.fn();
const dm = new DialogsManager();
- dm.on('open', { id: dialogId, listener: openListener });
- dm.on('close', { id: dialogId, listener: closeListener });
dm.open({ id: 'xxx' });
dm.open({ id: 'yyy' });
dm.toggleOpenSingle({ id: dialogId });
- expect(openListener).toHaveBeenCalledTimes(1);
- expect(closeListener).toHaveBeenCalledTimes(0);
- expect(dm.openDialogCount).toBe(1);
+ expect(dm.state.getLatestValue().openDialogCount).toBe(1);
dm.toggleOpenSingle({ id: dialogId });
- expect(openListener).toHaveBeenCalledTimes(1);
- expect(closeListener).toHaveBeenCalledTimes(1);
- expect(dm.openDialogCount).toBe(0);
- });
-
- it('removes a dialog if no associated dialog event listeners', () => {
- const openListener = jest.fn();
- const dm = new DialogsManager();
- dm.getOrCreate({ id: dialogId });
- dm.on('open', { id: dialogId, listener: openListener });
- dm.open({ id: dialogId });
- dm.off('open', { id: dialogId, listener: openListener });
- dm.remove(dialogId);
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(0);
- expect(dm.openDialogCount).toBe(0);
- expect(Object.keys(dm.dialogs)).toHaveLength(0);
+ expect(dm.state.getLatestValue().openDialogCount).toBe(0);
});
- it('does not remove a dialog if associated dialog event listeners', () => {
- const openListener = jest.fn();
+ it('removes a dialog', () => {
const dm = new DialogsManager();
dm.getOrCreate({ id: dialogId });
- dm.on('open', { id: dialogId, listener: openListener });
dm.open({ id: dialogId });
dm.remove(dialogId);
- expect(Object.keys(dm.dialogEventListeners[dialogId])).toHaveLength(1);
- expect(dm.openDialogCount).toBe(1);
- expect(Object.keys(dm.dialogs)).toHaveLength(1);
+ expect(dm.state.getLatestValue().openDialogCount).toBe(0);
+ expect(Object.keys(dm.state.getLatestValue().dialogs)).toHaveLength(0);
});
it('handles attempt to remove non-existent dialog', () => {
- const openListener = jest.fn();
const dm = new DialogsManager();
dm.getOrCreate({ id: dialogId });
- dm.on('open', { id: dialogId, listener: openListener });
dm.open({ id: dialogId });
dm.remove('xxx');
- expect(Object.keys(dm.dialogEventListeners)).toHaveLength(1);
- expect(dm.openDialogCount).toBe(1);
- expect(Object.keys(dm.dialogs)).toHaveLength(1);
+ expect(dm.state.getLatestValue().openDialogCount).toBe(1);
+ expect(Object.keys(dm.state.getLatestValue().dialogs)).toHaveLength(1);
});
});
diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts
index b98178346..77bab9acb 100644
--- a/src/components/Dialog/hooks/useDialog.ts
+++ b/src/components/Dialog/hooks/useDialog.ts
@@ -19,14 +19,17 @@ 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]);
+ useEffect(
+ () =>
+ dialogsManager.state.subscribeWithSelector(
+ ({ dialogs }) => [!!dialogs[id]?.isOpen],
+ ([isOpen]) => {
+ setOpen(isOpen);
+ },
+ // id,
+ ),
+ [dialogsManager, id, source],
+ );
return open;
};
diff --git a/src/components/MessageActions/__tests__/MessageActions.test.js b/src/components/MessageActions/__tests__/MessageActions.test.js
index 04677f656..6ebe04950 100644
--- a/src/components/MessageActions/__tests__/MessageActions.test.js
+++ b/src/components/MessageActions/__tests__/MessageActions.test.js
@@ -79,7 +79,7 @@ describe(' component', () => {
it('should render correctly when not open', () => {
const tree = renderMessageActions({}, testRenderer.create);
expect(tree.toJSON()).toMatchInlineSnapshot(`
- Array [
+ [
component', () => {
data-testid="str-chat__dialog-overlay"
onClick={[Function]}
style={
- Object {
+ {
"--str-chat__dialog-overlay-height": "0",
}
}
@@ -227,7 +227,7 @@ describe(' component', () => {
testRenderer.create,
);
expect(tree.toJSON()).toMatchInlineSnapshot(`
- Array [
+ [
component', () => {
data-testid="str-chat__dialog-overlay"
onClick={[Function]}
style={
- Object {
+ {
"--str-chat__dialog-overlay-height": "0",
}
}
@@ -277,7 +277,7 @@ describe(' component', () => {
testRenderer.create,
);
expect(tree.toJSON()).toMatchInlineSnapshot(`
- Array [
+ [
component', () => {
data-testid="str-chat__dialog-overlay"
onClick={[Function]}
style={
- Object {
+ {
"--str-chat__dialog-overlay-height": "0",
}
}
diff --git a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap
index 12550cff7..6b30d6519 100644
--- a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap
+++ b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap
@@ -67,18 +67,18 @@ exports[`VirtualizedMessageList should render the list without any message 1`] =
-
-
+ />
+
From f76cc75a67437119eab608b713f5c39052ad925c Mon Sep 17 00:00:00 2001
From: martincupela
Date: Wed, 11 Sep 2024 10:00:10 +0200
Subject: [PATCH 19/29] refactor: rename DialogsManager to DialogManager
---
.../{DialogsManager.ts => DialogManager.ts} | 30 +++++++++----------
src/components/Dialog/DialogPortal.tsx | 20 ++++++-------
.../Dialog/__tests__/DialogsManager.test.js | 30 +++++++++----------
src/components/Dialog/hooks/useDialog.ts | 18 +++++------
src/components/Dialog/index.ts | 4 +--
.../Message/__tests__/MessageOptions.test.js | 6 ++--
.../Message/__tests__/QuotedMessage.test.js | 6 ++--
.../__tests__/MessageActions.test.js | 12 ++++----
.../__tests__/MessageActionsBox.test.js | 14 ++++-----
src/components/MessageList/MessageList.tsx | 6 ++--
.../MessageList/VirtualizedMessageList.tsx | 6 ++--
.../VirtualizedMessageListComponents.test.js | 24 +++++++--------
.../VirtualizedMessageList.test.js.snap | 2 +-
...tualizedMessageListComponents.test.js.snap | 18 +++++------
.../__tests__/ReactionSelector.test.js | 6 ++--
src/context/DialogManagerContext.tsx | 27 +++++++++++++++++
src/context/DialogsManagerContext.tsx | 27 -----------------
src/context/index.ts | 2 +-
18 files changed, 128 insertions(+), 130 deletions(-)
rename src/components/Dialog/{DialogsManager.ts => DialogManager.ts} (83%)
create mode 100644 src/context/DialogManagerContext.tsx
delete mode 100644 src/context/DialogsManagerContext.tsx
diff --git a/src/components/Dialog/DialogsManager.ts b/src/components/Dialog/DialogManager.ts
similarity index 83%
rename from src/components/Dialog/DialogsManager.ts
rename to src/components/Dialog/DialogManager.ts
index 275f9f5ed..ace76c879 100644
--- a/src/components/Dialog/DialogsManager.ts
+++ b/src/components/Dialog/DialogManager.ts
@@ -22,14 +22,14 @@ type DialogInitOptions = {
type Dialogs = Record;
-type DialogsManagerState = {
+type DialogManagerState = {
dialogs: Dialogs;
openDialogCount: number;
};
-export class DialogsManager {
+export class DialogManager {
id: string;
- state = new StateStore({
+ state = new StateStore({
dialogs: {},
openDialogCount: 0,
});
@@ -84,7 +84,6 @@ export class DialogsManager {
close(id: DialogId) {
const dialog = this.state.getLatestValue().dialogs[id];
if (!dialog?.isOpen) return;
- dialog.isOpen = false;
this.state.next((current) => ({
...current,
dialogs: { ...current.dialogs, [dialog.id]: { ...dialog, isOpen: false } },
@@ -117,17 +116,16 @@ export class DialogsManager {
const dialog = state.dialogs[id];
if (!dialog) return;
- this.state.next((current) => ({
- ...current,
- dialogs: Object.entries(current.dialogs).reduce((acc, [dialogId, dialog]) => {
- if (id !== dialogId) {
- acc[id] = dialog;
- }
- return acc;
- }, {}),
- openDialogCount:
- current.openDialogCount &&
- (dialog.isOpen ? current.openDialogCount - 1 : current.openDialogCount),
- }));
+ this.state.next((current) => {
+ const newDialogs = { ...current.dialogs };
+ delete newDialogs[id];
+ return {
+ ...current,
+ dialogs: newDialogs,
+ openDialogCount:
+ current.openDialogCount &&
+ (dialog.isOpen ? current.openDialogCount - 1 : current.openDialogCount),
+ };
+ });
}
}
diff --git a/src/components/Dialog/DialogPortal.tsx b/src/components/Dialog/DialogPortal.tsx
index a01ced9d6..eb3e912c1 100644
--- a/src/components/Dialog/DialogPortal.tsx
+++ b/src/components/Dialog/DialogPortal.tsx
@@ -1,31 +1,31 @@
import React, { PropsWithChildren, useEffect, useLayoutEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useDialogIsOpen } from './hooks';
-import { useDialogsManager } from '../../context';
+import { useDialogManager } from '../../context';
export const DialogPortalDestination = () => {
- const { dialogsManager } = useDialogsManager();
+ const { dialogManager } = useDialogManager();
const [shouldRender, setShouldRender] = useState(
- !!dialogsManager.state.getLatestValue().openDialogCount,
+ !!dialogManager.state.getLatestValue().openDialogCount,
);
useEffect(
() =>
- dialogsManager.state.subscribeWithSelector(
+ dialogManager.state.subscribeWithSelector(
({ openDialogCount }) => [openDialogCount],
([openDialogCount]) => {
setShouldRender(openDialogCount > 0);
},
),
- [dialogsManager],
+ [dialogManager],
);
return (
dialogsManager.closeAll()}
+ onClick={() => dialogManager.closeAll()}
style={
{
'--str-chat__dialog-overlay-height': shouldRender ? '100%' : '0',
@@ -43,16 +43,16 @@ export const DialogPortalEntry = ({
children,
dialogId,
}: PropsWithChildren) => {
- const { dialogsManager } = useDialogsManager();
+ const { dialogManager } = useDialogManager();
const dialogIsOpen = useDialogIsOpen(dialogId);
const [portalDestination, setPortalDestination] = useState(null);
useLayoutEffect(() => {
const destination = document.querySelector(
- `div[data-str-chat__portal-id="${dialogsManager.id}"]`,
+ `div[data-str-chat__portal-id="${dialogManager.id}"]`,
);
if (!destination) return;
setPortalDestination(destination);
- }, [dialogsManager, dialogIsOpen]);
+ }, [dialogManager, dialogIsOpen]);
if (!portalDestination) return null;
diff --git a/src/components/Dialog/__tests__/DialogsManager.test.js b/src/components/Dialog/__tests__/DialogsManager.test.js
index cc2c999f8..537e8c0f6 100644
--- a/src/components/Dialog/__tests__/DialogsManager.test.js
+++ b/src/components/Dialog/__tests__/DialogsManager.test.js
@@ -1,22 +1,22 @@
-import { DialogsManager } from '../DialogsManager';
+import { DialogManager } from '../DialogManager';
const dialogId = 'dialogId';
describe('DialogManager', () => {
it('initiates with provided options', () => {
const id = 'XX';
- const dm = new DialogsManager({ id });
+ const dm = new DialogManager({ id });
expect(dm.id).toBe(id);
});
it('initiates with default options', () => {
const mockedId = '12345';
const spy = jest.spyOn(Date.prototype, 'getTime').mockReturnValueOnce(mockedId);
- const dm = new DialogsManager();
+ const dm = new DialogManager();
expect(dm.id).toBe(mockedId);
spy.mockRestore();
});
it('creates a new closed dialog', () => {
- const dm = new DialogsManager();
+ const dm = new DialogManager();
expect(Object.keys(dm.state.getLatestValue().dialogs)).toHaveLength(0);
expect(dm.getOrCreate({ id: dialogId })).toMatchObject({
close: expect.any(Function),
@@ -32,7 +32,7 @@ describe('DialogManager', () => {
});
it('retrieves an existing dialog', () => {
- const dm = new DialogsManager();
+ const dm = new DialogManager();
dm.state.next((current) => ({
...current,
dialogs: { ...current.dialogs, [dialogId]: { id: dialogId, isOpen: true } },
@@ -45,7 +45,7 @@ describe('DialogManager', () => {
});
it('creates a dialog if it does not exist on open', () => {
- const dm = new DialogsManager();
+ const dm = new DialogManager();
dm.open({ id: dialogId });
expect(dm.state.getLatestValue().dialogs[dialogId]).toMatchObject({
close: expect.any(Function),
@@ -60,7 +60,7 @@ describe('DialogManager', () => {
});
it('opens existing dialog', () => {
- const dm = new DialogsManager();
+ const dm = new DialogManager();
dm.getOrCreate({ id: dialogId });
dm.open({ id: dialogId });
expect(dm.state.getLatestValue().dialogs[dialogId].isOpen).toBeTruthy();
@@ -68,7 +68,7 @@ describe('DialogManager', () => {
});
it('does not open already open dialog', () => {
- const dm = new DialogsManager();
+ const dm = new DialogManager();
dm.getOrCreate({ id: dialogId });
dm.open({ id: dialogId });
dm.open({ id: dialogId });
@@ -76,7 +76,7 @@ describe('DialogManager', () => {
});
it('closes all other dialogs before opening the target', () => {
- const dm = new DialogsManager();
+ const dm = new DialogManager();
dm.open({ id: 'xxx' });
dm.open({ id: 'yyy' });
expect(dm.state.getLatestValue().openDialogCount).toBe(2);
@@ -89,7 +89,7 @@ describe('DialogManager', () => {
});
it('closes opened dialog', () => {
- const dm = new DialogsManager();
+ const dm = new DialogManager();
dm.open({ id: dialogId });
dm.close(dialogId);
expect(dm.state.getLatestValue().dialogs[dialogId].isOpen).toBeFalsy();
@@ -97,7 +97,7 @@ describe('DialogManager', () => {
});
it('does not close already closed dialog', () => {
- const dm = new DialogsManager();
+ const dm = new DialogManager();
dm.open({ id: 'xxx' });
dm.open({ id: dialogId });
dm.close(dialogId);
@@ -106,7 +106,7 @@ describe('DialogManager', () => {
});
it('toggles the open state of a dialog', () => {
- const dm = new DialogsManager();
+ const dm = new DialogManager();
dm.open({ id: 'xxx' });
dm.open({ id: 'yyy' });
dm.toggleOpen({ id: dialogId });
@@ -116,7 +116,7 @@ describe('DialogManager', () => {
});
it('keeps single opened dialog when the toggling open dialog state', () => {
- const dm = new DialogsManager();
+ const dm = new DialogManager();
dm.open({ id: 'xxx' });
dm.open({ id: 'yyy' });
@@ -128,7 +128,7 @@ describe('DialogManager', () => {
});
it('removes a dialog', () => {
- const dm = new DialogsManager();
+ const dm = new DialogManager();
dm.getOrCreate({ id: dialogId });
dm.open({ id: dialogId });
dm.remove(dialogId);
@@ -137,7 +137,7 @@ describe('DialogManager', () => {
});
it('handles attempt to remove non-existent dialog', () => {
- const dm = new DialogsManager();
+ const dm = new DialogManager();
dm.getOrCreate({ id: dialogId });
dm.open({ id: dialogId });
dm.remove('xxx');
diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts
index 77bab9acb..95d0ac4db 100644
--- a/src/components/Dialog/hooks/useDialog.ts
+++ b/src/components/Dialog/hooks/useDialog.ts
@@ -1,34 +1,34 @@
import { useEffect, useState } from 'react';
-import { useDialogsManager } from '../../../context/DialogsManagerContext';
-import type { GetOrCreateParams } from '../DialogsManager';
+import { useDialogManager } from '../../../context/DialogManagerContext';
+import type { GetOrCreateParams } from '../DialogManager';
export const useDialog = ({ id }: GetOrCreateParams) => {
- const { dialogsManager } = useDialogsManager();
+ const { dialogManager } = useDialogManager();
useEffect(
() => () => {
- dialogsManager.remove(id);
+ dialogManager.remove(id);
},
- [dialogsManager, id],
+ [dialogManager, id],
);
- return dialogsManager.getOrCreate({ id });
+ return dialogManager.getOrCreate({ id });
};
export const useDialogIsOpen = (id: string, source?: string) => {
- const { dialogsManager } = useDialogsManager();
+ const { dialogManager } = useDialogManager();
const [open, setOpen] = useState(false);
useEffect(
() =>
- dialogsManager.state.subscribeWithSelector(
+ dialogManager.state.subscribeWithSelector(
({ dialogs }) => [!!dialogs[id]?.isOpen],
([isOpen]) => {
setOpen(isOpen);
},
// id,
),
- [dialogsManager, id, source],
+ [dialogManager, id, source],
);
return open;
diff --git a/src/components/Dialog/index.ts b/src/components/Dialog/index.ts
index 3bfd1c2dc..a2462dbcd 100644
--- a/src/components/Dialog/index.ts
+++ b/src/components/Dialog/index.ts
@@ -1,4 +1,4 @@
export * from './DialogAnchor';
-export * from './DialogsManager';
-export * from '../../context/DialogsManagerContext';
+export * from './DialogManager';
+export * from './DialogPortal';
export * from './hooks';
diff --git a/src/components/Message/__tests__/MessageOptions.test.js b/src/components/Message/__tests__/MessageOptions.test.js
index f3b569c56..718cc9522 100644
--- a/src/components/Message/__tests__/MessageOptions.test.js
+++ b/src/components/Message/__tests__/MessageOptions.test.js
@@ -21,7 +21,7 @@ import {
generateUser,
getTestClientWithUser,
} from '../../../mock-builders';
-import { DialogsManagerProvider } from '../../../context';
+import { DialogManagerProvider } from '../../../context';
import { defaultReactionOptions } from '../../Reactions';
const MESSAGE_ACTIONS_TEST_ID = 'message-actions';
@@ -55,7 +55,7 @@ async function renderMessageOptions({
return render(
-
+
-
+
,
);
}
diff --git a/src/components/Message/__tests__/QuotedMessage.test.js b/src/components/Message/__tests__/QuotedMessage.test.js
index b076be757..64f8beda8 100644
--- a/src/components/Message/__tests__/QuotedMessage.test.js
+++ b/src/components/Message/__tests__/QuotedMessage.test.js
@@ -9,7 +9,7 @@ import {
ChannelStateProvider,
ChatProvider,
ComponentProvider,
- DialogsManagerProvider,
+ DialogManagerProvider,
TranslationProvider,
} from '../../../context';
import {
@@ -66,11 +66,11 @@ async function renderQuotedMessage(customProps) {
Message: () => ,
}}
>
-
+
-
+
diff --git a/src/components/MessageActions/__tests__/MessageActions.test.js b/src/components/MessageActions/__tests__/MessageActions.test.js
index 6ebe04950..1e03d80b3 100644
--- a/src/components/MessageActions/__tests__/MessageActions.test.js
+++ b/src/components/MessageActions/__tests__/MessageActions.test.js
@@ -10,7 +10,7 @@ import {
ChannelStateProvider,
ChatProvider,
ComponentProvider,
- DialogsManagerProvider,
+ DialogManagerProvider,
MessageProvider,
TranslationProvider,
} from '../../../context';
@@ -49,7 +49,7 @@ const chatClient = getTestClient();
function renderMessageActions(customProps = {}, renderer = render) {
return renderer(
-
+
@@ -59,7 +59,7 @@ function renderMessageActions(customProps = {}, renderer = render) {
-
+
,
);
}
@@ -108,7 +108,7 @@ describe(' component', () => {
,
component', () => {
,
component', () => {
,
-
+
-
+
@@ -227,14 +227,14 @@ describe('MessageActionsBox', () => {
await render(
-
+
-
+
,
);
@@ -398,10 +398,10 @@ describe('MessageActionsBox', () => {
await render(
-
+
-
+
,
);
diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx
index 0ea696511..9a244c901 100644
--- a/src/components/MessageList/MessageList.tsx
+++ b/src/components/MessageList/MessageList.tsx
@@ -21,7 +21,7 @@ import {
ChannelStateContextValue,
useChannelStateContext,
} from '../../context/ChannelStateContext';
-import { DialogsManagerProvider } from '../../context';
+import { DialogManagerProvider } from '../../context';
import { useChatContext } from '../../context/ChatContext';
import { useComponentContext } from '../../context/ComponentContext';
import { MessageListContextProvider } from '../../context/MessageListContext';
@@ -224,7 +224,7 @@ const MessageListWithContext = <
return (
-
+
{!threadList && showUnreadMessagesNotification && (
)}
@@ -263,7 +263,7 @@ const MessageListWithContext = <
)}
-
+
-
+
{!threadList && showUnreadMessagesNotification && (
)}
@@ -499,7 +499,7 @@ const VirtualizedMessageListWithContext = <
{...(defaultItemHeight ? { defaultItemHeight } : {})}
/>
-
+
{TypingIndicator && }
(
-
+
{children}
-
+
@@ -92,7 +92,7 @@ describe('VirtualizedMessageComponents', () => {
@@ -121,7 +121,7 @@ describe('VirtualizedMessageComponents', () => {
@@ -136,7 +136,7 @@ describe('VirtualizedMessageComponents', () => {
@@ -165,7 +165,7 @@ describe('VirtualizedMessageComponents', () => {
@@ -183,7 +183,7 @@ describe('VirtualizedMessageComponents', () => {
@@ -209,7 +209,7 @@ describe('VirtualizedMessageComponents', () => {
@@ -235,7 +235,7 @@ describe('VirtualizedMessageComponents', () => {
@@ -262,7 +262,7 @@ describe('VirtualizedMessageComponents', () => {
@@ -280,7 +280,7 @@ describe('VirtualizedMessageComponents', () => {
diff --git a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap
index 6b30d6519..7c708d272 100644
--- a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap
+++ b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap
@@ -69,7 +69,7 @@ exports[`VirtualizedMessageList should render the list without any message 1`] =
@@ -25,7 +25,7 @@ exports[`VirtualizedMessageComponents EmptyPlaceholder should render custom Empt
@@ -59,7 +59,7 @@ exports[`VirtualizedMessageComponents EmptyPlaceholder should render for main me
@@ -117,7 +117,7 @@ exports[`VirtualizedMessageComponents Header should not render custom head in He
@@ -172,7 +172,7 @@ exports[`VirtualizedMessageComponents Header should render LoadingIndicator in H
@@ -187,7 +187,7 @@ exports[`VirtualizedMessageComponents Item should render wrapper with custom cla
/>
@@ -202,7 +202,7 @@ exports[`VirtualizedMessageComponents Item should render wrapper with custom cla
/>
@@ -217,7 +217,7 @@ exports[`VirtualizedMessageComponents Item should render wrapper without custom
/>
@@ -232,7 +232,7 @@ exports[`VirtualizedMessageComponents Item should render wrapper without custom
/>
diff --git a/src/components/Reactions/__tests__/ReactionSelector.test.js b/src/components/Reactions/__tests__/ReactionSelector.test.js
index 0c159777e..3b668ea76 100644
--- a/src/components/Reactions/__tests__/ReactionSelector.test.js
+++ b/src/components/Reactions/__tests__/ReactionSelector.test.js
@@ -13,7 +13,7 @@ import { Avatar as AvatarMock } from '../../Avatar';
import { ComponentProvider } from '../../../context/ComponentContext';
import { MessageProvider } from '../../../context/MessageContext';
-import { DialogsManagerProvider } from '../../../context';
+import { DialogManagerProvider } from '../../../context';
import { generateMessage, generateReaction, generateUser } from '../../../mock-builders';
@@ -36,13 +36,13 @@ const handleReactionMock = jest.fn();
const renderComponent = (props) =>
render(
-
+
- ,
+ ,
);
describe('ReactionSelector', () => {
diff --git a/src/context/DialogManagerContext.tsx b/src/context/DialogManagerContext.tsx
new file mode 100644
index 000000000..b1f14126d
--- /dev/null
+++ b/src/context/DialogManagerContext.tsx
@@ -0,0 +1,27 @@
+import React, { PropsWithChildren, useContext, useState } from 'react';
+import { DialogManager } from '../components/Dialog/DialogManager';
+import { DialogPortalDestination } from '../components/Dialog/DialogPortal';
+
+type DialogManagerProviderContextValue = {
+ dialogManager: DialogManager;
+};
+
+const DialogManagerProviderContext = React.createContext<
+ DialogManagerProviderContextValue | undefined
+>(undefined);
+
+export const DialogManagerProvider = ({ children, id }: PropsWithChildren<{ id?: string }>) => {
+ const [dialogManager] = useState(() => new DialogManager({ id }));
+
+ return (
+
+ {children}
+
+
+ );
+};
+
+export const useDialogManager = () => {
+ const value = useContext(DialogManagerProviderContext);
+ return value as DialogManagerProviderContextValue;
+};
diff --git a/src/context/DialogsManagerContext.tsx b/src/context/DialogsManagerContext.tsx
deleted file mode 100644
index 1e564e7ee..000000000
--- a/src/context/DialogsManagerContext.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import React, { PropsWithChildren, useContext, useState } from 'react';
-import { DialogsManager } from '../components/Dialog/DialogsManager';
-import { DialogPortalDestination } from '../components/Dialog/DialogPortal';
-
-type DialogsManagerProviderContextValue = {
- dialogsManager: DialogsManager;
-};
-
-const DialogsManagerProviderContext = React.createContext<
- DialogsManagerProviderContextValue | undefined
->(undefined);
-
-export const DialogsManagerProvider = ({ children, id }: PropsWithChildren<{ id?: string }>) => {
- const [dialogsManager] = useState(() => new DialogsManager({ id }));
-
- return (
-
- {children}
-
-
- );
-};
-
-export const useDialogsManager = () => {
- const value = useContext(DialogsManagerProviderContext);
- return value as DialogsManagerProviderContextValue;
-};
diff --git a/src/context/index.ts b/src/context/index.ts
index 1dca38291..15e3f422b 100644
--- a/src/context/index.ts
+++ b/src/context/index.ts
@@ -3,7 +3,7 @@ export * from './ChannelListContext';
export * from './ChannelStateContext';
export * from './ChatContext';
export * from './ComponentContext';
-export * from './DialogsManagerContext';
+export * from './DialogManagerContext';
export * from './MessageContext';
export * from './MessageBounceContext';
export * from './MessageInputContext';
From cb52d15e98db4a0534429fe1c40d2e61eedf77a6 Mon Sep 17 00:00:00 2001
From: martincupela
Date: Wed, 11 Sep 2024 10:10:21 +0200
Subject: [PATCH 20/29] refactor: rename DialogsManager.open param single to
closeRest
---
src/components/Dialog/DialogManager.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/components/Dialog/DialogManager.ts b/src/components/Dialog/DialogManager.ts
index ace76c879..1882eb32b 100644
--- a/src/components/Dialog/DialogManager.ts
+++ b/src/components/Dialog/DialogManager.ts
@@ -68,10 +68,10 @@ export class DialogManager {
return dialog;
}
- open(params: GetOrCreateParams, single?: boolean) {
+ open(params: GetOrCreateParams, closeRest?: boolean) {
const dialog = this.getOrCreate(params);
if (dialog.isOpen) return;
- if (single) {
+ if (closeRest) {
this.closeAll();
}
this.state.next((current) => ({
From 45c5eb742e43e5ca2193008d33d124067262ca77 Mon Sep 17 00:00:00 2001
From: martincupela
Date: Wed, 11 Sep 2024 10:12:43 +0200
Subject: [PATCH 21/29] refactor: rename DialogsManager.state.dialogs to
DialogsManager.state.dialogsById
---
src/components/Dialog/DialogManager.ts | 26 +++++++++----------
.../Dialog/__tests__/DialogsManager.test.js | 24 ++++++++---------
src/components/Dialog/hooks/useDialog.ts | 2 +-
3 files changed, 26 insertions(+), 26 deletions(-)
diff --git a/src/components/Dialog/DialogManager.ts b/src/components/Dialog/DialogManager.ts
index 1882eb32b..479dd63c4 100644
--- a/src/components/Dialog/DialogManager.ts
+++ b/src/components/Dialog/DialogManager.ts
@@ -23,14 +23,14 @@ type DialogInitOptions = {
type Dialogs = Record;
type DialogManagerState = {
- dialogs: Dialogs;
+ dialogsById: Dialogs;
openDialogCount: number;
};
export class DialogManager {
id: string;
state = new StateStore({
- dialogs: {},
+ dialogsById: {},
openDialogCount: 0,
});
@@ -39,7 +39,7 @@ export class DialogManager {
}
getOrCreate({ id }: GetOrCreateParams) {
- let dialog = this.state.getLatestValue().dialogs[id];
+ let dialog = this.state.getLatestValue().dialogsById[id];
if (!dialog) {
dialog = {
close: () => {
@@ -62,7 +62,7 @@ export class DialogManager {
};
this.state.next((current) => ({
...current,
- ...{ dialogs: { ...current.dialogs, [id]: dialog } },
+ ...{ dialogsById: { ...current.dialogsById, [id]: dialog } },
}));
}
return dialog;
@@ -76,27 +76,27 @@ export class DialogManager {
}
this.state.next((current) => ({
...current,
- dialogs: { ...current.dialogs, [dialog.id]: { ...dialog, isOpen: true } },
+ dialogsById: { ...current.dialogsById, [dialog.id]: { ...dialog, isOpen: true } },
openDialogCount: ++current.openDialogCount,
}));
}
close(id: DialogId) {
- const dialog = this.state.getLatestValue().dialogs[id];
+ const dialog = this.state.getLatestValue().dialogsById[id];
if (!dialog?.isOpen) return;
this.state.next((current) => ({
...current,
- dialogs: { ...current.dialogs, [dialog.id]: { ...dialog, isOpen: false } },
+ dialogsById: { ...current.dialogsById, [dialog.id]: { ...dialog, isOpen: false } },
openDialogCount: --current.openDialogCount,
}));
}
closeAll() {
- Object.values(this.state.getLatestValue().dialogs).forEach((dialog) => dialog.close());
+ Object.values(this.state.getLatestValue().dialogsById).forEach((dialog) => dialog.close());
}
toggleOpen(params: GetOrCreateParams) {
- if (this.state.getLatestValue().dialogs[params.id]?.isOpen) {
+ if (this.state.getLatestValue().dialogsById[params.id]?.isOpen) {
this.close(params.id);
} else {
this.open(params);
@@ -104,7 +104,7 @@ export class DialogManager {
}
toggleOpenSingle(params: GetOrCreateParams) {
- if (this.state.getLatestValue().dialogs[params.id]?.isOpen) {
+ if (this.state.getLatestValue().dialogsById[params.id]?.isOpen) {
this.close(params.id);
} else {
this.open(params, true);
@@ -113,15 +113,15 @@ export class DialogManager {
remove(id: DialogId) {
const state = this.state.getLatestValue();
- const dialog = state.dialogs[id];
+ const dialog = state.dialogsById[id];
if (!dialog) return;
this.state.next((current) => {
- const newDialogs = { ...current.dialogs };
+ const newDialogs = { ...current.dialogsById };
delete newDialogs[id];
return {
...current,
- dialogs: newDialogs,
+ dialogsById: newDialogs,
openDialogCount:
current.openDialogCount &&
(dialog.isOpen ? current.openDialogCount - 1 : current.openDialogCount),
diff --git a/src/components/Dialog/__tests__/DialogsManager.test.js b/src/components/Dialog/__tests__/DialogsManager.test.js
index 537e8c0f6..23895e8c5 100644
--- a/src/components/Dialog/__tests__/DialogsManager.test.js
+++ b/src/components/Dialog/__tests__/DialogsManager.test.js
@@ -17,7 +17,7 @@ describe('DialogManager', () => {
});
it('creates a new closed dialog', () => {
const dm = new DialogManager();
- expect(Object.keys(dm.state.getLatestValue().dialogs)).toHaveLength(0);
+ expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(0);
expect(dm.getOrCreate({ id: dialogId })).toMatchObject({
close: expect.any(Function),
id: 'dialogId',
@@ -27,7 +27,7 @@ describe('DialogManager', () => {
toggle: expect.any(Function),
toggleSingle: expect.any(Function),
});
- expect(Object.keys(dm.state.getLatestValue().dialogs)).toHaveLength(1);
+ expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(1);
expect(dm.state.getLatestValue().openDialogCount).toBe(0);
});
@@ -35,19 +35,19 @@ describe('DialogManager', () => {
const dm = new DialogManager();
dm.state.next((current) => ({
...current,
- dialogs: { ...current.dialogs, [dialogId]: { id: dialogId, isOpen: true } },
+ dialogsById: { ...current.dialogsById, [dialogId]: { id: dialogId, isOpen: true } },
}));
expect(dm.getOrCreate({ id: dialogId })).toMatchObject({
id: 'dialogId',
isOpen: true,
});
- expect(Object.keys(dm.state.getLatestValue().dialogs)).toHaveLength(1);
+ expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(1);
});
it('creates a dialog if it does not exist on open', () => {
const dm = new DialogManager();
dm.open({ id: dialogId });
- expect(dm.state.getLatestValue().dialogs[dialogId]).toMatchObject({
+ expect(dm.state.getLatestValue().dialogsById[dialogId]).toMatchObject({
close: expect.any(Function),
id: 'dialogId',
isOpen: true,
@@ -63,7 +63,7 @@ describe('DialogManager', () => {
const dm = new DialogManager();
dm.getOrCreate({ id: dialogId });
dm.open({ id: dialogId });
- expect(dm.state.getLatestValue().dialogs[dialogId].isOpen).toBeTruthy();
+ expect(dm.state.getLatestValue().dialogsById[dialogId].isOpen).toBeTruthy();
expect(dm.state.getLatestValue().openDialogCount).toBe(1);
});
@@ -75,16 +75,16 @@ describe('DialogManager', () => {
expect(dm.state.getLatestValue().openDialogCount).toBe(1);
});
- it('closes all other dialogs before opening the target', () => {
+ it('closes all other dialogsById before opening the target', () => {
const dm = new DialogManager();
dm.open({ id: 'xxx' });
dm.open({ id: 'yyy' });
expect(dm.state.getLatestValue().openDialogCount).toBe(2);
dm.open({ id: dialogId }, true);
- const dialogs = dm.state.getLatestValue().dialogs;
+ const dialogs = dm.state.getLatestValue().dialogsById;
expect(dialogs.xxx.isOpen).toBeFalsy();
expect(dialogs.yyy.isOpen).toBeFalsy();
- expect(dm.state.getLatestValue().dialogs[dialogId].isOpen).toBeTruthy();
+ expect(dm.state.getLatestValue().dialogsById[dialogId].isOpen).toBeTruthy();
expect(dm.state.getLatestValue().openDialogCount).toBe(1);
});
@@ -92,7 +92,7 @@ describe('DialogManager', () => {
const dm = new DialogManager();
dm.open({ id: dialogId });
dm.close(dialogId);
- expect(dm.state.getLatestValue().dialogs[dialogId].isOpen).toBeFalsy();
+ expect(dm.state.getLatestValue().dialogsById[dialogId].isOpen).toBeFalsy();
expect(dm.state.getLatestValue().openDialogCount).toBe(0);
});
@@ -133,7 +133,7 @@ describe('DialogManager', () => {
dm.open({ id: dialogId });
dm.remove(dialogId);
expect(dm.state.getLatestValue().openDialogCount).toBe(0);
- expect(Object.keys(dm.state.getLatestValue().dialogs)).toHaveLength(0);
+ expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(0);
});
it('handles attempt to remove non-existent dialog', () => {
@@ -142,6 +142,6 @@ describe('DialogManager', () => {
dm.open({ id: dialogId });
dm.remove('xxx');
expect(dm.state.getLatestValue().openDialogCount).toBe(1);
- expect(Object.keys(dm.state.getLatestValue().dialogs)).toHaveLength(1);
+ expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(1);
});
});
diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts
index 95d0ac4db..f3ab3ab39 100644
--- a/src/components/Dialog/hooks/useDialog.ts
+++ b/src/components/Dialog/hooks/useDialog.ts
@@ -22,7 +22,7 @@ export const useDialogIsOpen = (id: string, source?: string) => {
useEffect(
() =>
dialogManager.state.subscribeWithSelector(
- ({ dialogs }) => [!!dialogs[id]?.isOpen],
+ ({ dialogsById }) => [!!dialogsById[id]?.isOpen],
([isOpen]) => {
setOpen(isOpen);
},
From 401af819ef9b7ab2a7c8b436361a638d12ceda0c Mon Sep 17 00:00:00 2001
From: martincupela
Date: Wed, 11 Sep 2024 12:13:26 +0200
Subject: [PATCH 22/29] refactor: move useStateStore to src/store/hooks
---
src/components/ChatView/ChatView.tsx | 3 ++-
src/components/Message/MessageOptions.tsx | 10 +++++-----
.../Message/__tests__/MessageOptions.test.js | 14 ++++++++------
src/components/Thread/Thread.tsx | 3 ++-
src/components/Threads/ThreadList/ThreadList.tsx | 2 +-
.../Threads/ThreadList/ThreadListItemUI.tsx | 2 +-
.../ThreadList/ThreadListLoadingIndicator.tsx | 2 +-
.../ThreadList/ThreadListUnseenThreadsBanner.tsx | 2 +-
.../Threads/hooks/useThreadManagerState.ts | 2 +-
src/components/Threads/hooks/useThreadState.ts | 2 +-
src/components/Threads/index.ts | 1 -
src/index.ts | 1 +
src/store/hooks/index.ts | 1 +
.../Threads => store}/hooks/useStateStore.ts | 0
src/store/index.ts | 1 +
15 files changed, 26 insertions(+), 20 deletions(-)
create mode 100644 src/store/hooks/index.ts
rename src/{components/Threads => store}/hooks/useStateStore.ts (100%)
create mode 100644 src/store/index.ts
diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx
index 5d6aea889..f14261dee 100644
--- a/src/components/ChatView/ChatView.tsx
+++ b/src/components/ChatView/ChatView.tsx
@@ -1,9 +1,10 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
-import { ThreadProvider, useStateStore } from '../Threads';
+import { ThreadProvider } from '../Threads';
import { Icon } from '../Threads/icons';
import { UnreadCountBadge } from '../Threads/UnreadCountBadge';
import { useChatContext } from '../../context';
+import { useStateStore } from '../../store';
import type { PropsWithChildren } from 'react';
import type { Thread, ThreadManagerState } from 'stream-chat';
diff --git a/src/components/Message/MessageOptions.tsx b/src/components/Message/MessageOptions.tsx
index f8890ea21..3da40fcb8 100644
--- a/src/components/Message/MessageOptions.tsx
+++ b/src/components/Message/MessageOptions.tsx
@@ -1,3 +1,4 @@
+import clsx from 'clsx';
import React from 'react';
import {
@@ -7,14 +8,13 @@ import {
} from './icons';
import { MESSAGE_ACTIONS } from './utils';
import { MessageActions } from '../MessageActions';
+import { useDialogIsOpen } from '../Dialog';
+import { ReactionSelectorWithButton } from '../Reactions/ReactionSelectorWithButton';
-import { useTranslationContext } from '../../context';
-import { MessageContextValue, useMessageContext } from '../../context/MessageContext';
+import { useMessageContext, useTranslationContext } from '../../context';
import type { DefaultStreamChatGenerics, IconProps } from '../../types/types';
-import { ReactionSelectorWithButton } from '../Reactions/ReactionSelectorWithButton';
-import { useDialogIsOpen } from '../Dialog';
-import clsx from 'clsx';
+import type { MessageContextValue } from '../../context/MessageContext';
export type MessageOptionsProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
diff --git a/src/components/Message/__tests__/MessageOptions.test.js b/src/components/Message/__tests__/MessageOptions.test.js
index 718cc9522..1b2162fa0 100644
--- a/src/components/Message/__tests__/MessageOptions.test.js
+++ b/src/components/Message/__tests__/MessageOptions.test.js
@@ -9,11 +9,15 @@ import { MessageSimple } from '../MessageSimple';
import { ACTIONS_NOT_WORKING_IN_THREAD, MESSAGE_ACTIONS } from '../utils';
import { Attachment } from '../../Attachment';
+import { defaultReactionOptions } from '../../Reactions';
-import { ChannelActionProvider } from '../../../context/ChannelActionContext';
-import { ChannelStateProvider } from '../../../context/ChannelStateContext';
-import { ChatProvider } from '../../../context/ChatContext';
-import { ComponentProvider } from '../../../context/ComponentContext';
+import {
+ ChannelActionProvider,
+ ChannelStateProvider,
+ ChatProvider,
+ ComponentProvider,
+ DialogManagerProvider,
+} from '../../../context';
import {
generateChannel,
@@ -21,8 +25,6 @@ import {
generateUser,
getTestClientWithUser,
} from '../../../mock-builders';
-import { DialogManagerProvider } from '../../../context';
-import { defaultReactionOptions } from '../../Reactions';
const MESSAGE_ACTIONS_TEST_ID = 'message-actions';
diff --git a/src/components/Thread/Thread.tsx b/src/components/Thread/Thread.tsx
index 6ede0894a..a02b1c737 100644
--- a/src/components/Thread/Thread.tsx
+++ b/src/components/Thread/Thread.tsx
@@ -18,7 +18,8 @@ import {
useChatContext,
useComponentContext,
} from '../../context';
-import { useStateStore, useThreadContext } from '../../components/Threads';
+import { useThreadContext } from '../Threads';
+import { useStateStore } from '../../store';
import type { MessageProps, MessageUIComponentProps } from '../Message/types';
import type { MessageActionsArray } from '../Message/utils';
diff --git a/src/components/Threads/ThreadList/ThreadList.tsx b/src/components/Threads/ThreadList/ThreadList.tsx
index fdec3a520..e397bd427 100644
--- a/src/components/Threads/ThreadList/ThreadList.tsx
+++ b/src/components/Threads/ThreadList/ThreadList.tsx
@@ -8,7 +8,7 @@ import { ThreadListEmptyPlaceholder as DefaultThreadListEmptyPlaceholder } from
import { ThreadListUnseenThreadsBanner as DefaultThreadListUnseenThreadsBanner } from './ThreadListUnseenThreadsBanner';
import { ThreadListLoadingIndicator as DefaultThreadListLoadingIndicator } from './ThreadListLoadingIndicator';
import { useChatContext, useComponentContext } from '../../../context';
-import { useStateStore } from '../hooks/useStateStore';
+import { useStateStore } from '../../../store';
const selector = (nextValue: ThreadManagerState) => [nextValue.threads] as const;
diff --git a/src/components/Threads/ThreadList/ThreadListItemUI.tsx b/src/components/Threads/ThreadList/ThreadListItemUI.tsx
index f64ffdc86..f1cef2dd0 100644
--- a/src/components/Threads/ThreadList/ThreadListItemUI.tsx
+++ b/src/components/Threads/ThreadList/ThreadListItemUI.tsx
@@ -11,7 +11,7 @@ import { UnreadCountBadge } from '../UnreadCountBadge';
import { useChatContext } from '../../../context';
import { useThreadsViewContext } from '../../ChatView';
import { useThreadListItemContext } from './ThreadListItem';
-import { useStateStore } from '../hooks/useStateStore';
+import { useStateStore } from '../../../store';
export type ThreadListItemUIProps = ComponentPropsWithoutRef<'button'>;
diff --git a/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx b/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx
index da9da4ea4..e778b3035 100644
--- a/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx
+++ b/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx
@@ -4,7 +4,7 @@ import type { ThreadManagerState } from 'stream-chat';
import { LoadingIndicator as DefaultLoadingIndicator } from '../../Loading';
import { useChatContext, useComponentContext } from '../../../context';
-import { useStateStore } from '../hooks/useStateStore';
+import { useStateStore } from '../../../store';
const selector = (nextValue: ThreadManagerState) => [nextValue.pagination.isLoadingNext];
diff --git a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx
index 5d2178002..c7409f5ae 100644
--- a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx
+++ b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx
@@ -4,7 +4,7 @@ import type { ThreadManagerState } from 'stream-chat';
import { Icon } from '../icons';
import { useChatContext } from '../../../context';
-import { useStateStore } from '../hooks/useStateStore';
+import { useStateStore } from '../../../store';
const selector = (nextValue: ThreadManagerState) => [nextValue.unseenThreadIds] as const;
diff --git a/src/components/Threads/hooks/useThreadManagerState.ts b/src/components/Threads/hooks/useThreadManagerState.ts
index 1ee2e85b2..18ac8c7fd 100644
--- a/src/components/Threads/hooks/useThreadManagerState.ts
+++ b/src/components/Threads/hooks/useThreadManagerState.ts
@@ -1,6 +1,6 @@
import { useChatContext } from 'context';
-import { useStateStore } from './useStateStore';
import { ThreadManagerState } from 'stream-chat';
+import { useStateStore } from '../../../store';
export const useThreadManagerState = (
selector: (nextValue: ThreadManagerState) => T,
diff --git a/src/components/Threads/hooks/useThreadState.ts b/src/components/Threads/hooks/useThreadState.ts
index be02838ef..f6d8eb7a8 100644
--- a/src/components/Threads/hooks/useThreadState.ts
+++ b/src/components/Threads/hooks/useThreadState.ts
@@ -1,7 +1,7 @@
import { ThreadState } from 'stream-chat';
-import { useStateStore } from './useStateStore';
import { useThreadListItemContext } from '../ThreadList';
import { useThreadContext } from '../ThreadContext';
+import { useStateStore } from '../../../store/';
/**
* @description returns thread state, prioritizes `ThreadListItemContext` falls back to `ThreadContext` if not former is not present
diff --git a/src/components/Threads/index.ts b/src/components/Threads/index.ts
index 7347139bd..454098f8c 100644
--- a/src/components/Threads/index.ts
+++ b/src/components/Threads/index.ts
@@ -1,3 +1,2 @@
export * from './ThreadContext';
export * from './ThreadList';
-export * from './hooks/useStateStore';
diff --git a/src/index.ts b/src/index.ts
index e5eb9f321..b86ce062d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,5 +1,6 @@
export * from './components';
export * from './context';
export * from './i18n';
+export * from './store';
export * from './types';
export * from './utils';
diff --git a/src/store/hooks/index.ts b/src/store/hooks/index.ts
new file mode 100644
index 000000000..5a67cce00
--- /dev/null
+++ b/src/store/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useStateStore';
diff --git a/src/components/Threads/hooks/useStateStore.ts b/src/store/hooks/useStateStore.ts
similarity index 100%
rename from src/components/Threads/hooks/useStateStore.ts
rename to src/store/hooks/useStateStore.ts
diff --git a/src/store/index.ts b/src/store/index.ts
new file mode 100644
index 000000000..4cc90d02b
--- /dev/null
+++ b/src/store/index.ts
@@ -0,0 +1 @@
+export * from './hooks';
From 4f120a4a32ff8b523232f299ac6e25df16eee2fe Mon Sep 17 00:00:00 2001
From: martincupela
Date: Wed, 11 Sep 2024 12:14:16 +0200
Subject: [PATCH 23/29] refactor: remove openDialogCount from DialogManager
---
src/components/Dialog/DialogManager.ts | 34 ++---
src/components/Dialog/DialogPortal.tsx | 21 +--
.../Dialog/__tests__/DialogsManager.test.js | 144 +++++++++---------
src/components/Dialog/hooks/useDialog.ts | 43 +++---
4 files changed, 118 insertions(+), 124 deletions(-)
diff --git a/src/components/Dialog/DialogManager.ts b/src/components/Dialog/DialogManager.ts
index 479dd63c4..320519eef 100644
--- a/src/components/Dialog/DialogManager.ts
+++ b/src/components/Dialog/DialogManager.ts
@@ -1,11 +1,11 @@
import { StateStore } from 'stream-chat';
-type DialogId = string;
-
-export type GetOrCreateParams = {
+export type GetOrCreateDialogParams = {
id: DialogId;
};
+type DialogId = string;
+
export type Dialog = {
close: () => void;
id: DialogId;
@@ -16,29 +16,34 @@ export type Dialog = {
toggleSingle: () => void;
};
-type DialogInitOptions = {
+export type DialogManagerOptions = {
id?: string;
};
type Dialogs = Record;
-type DialogManagerState = {
+export type DialogManagerState = {
dialogsById: Dialogs;
- openDialogCount: number;
};
export class DialogManager {
id: string;
state = new StateStore({
dialogsById: {},
- openDialogCount: 0,
});
- constructor({ id }: DialogInitOptions = {}) {
+ constructor({ id }: DialogManagerOptions = {}) {
this.id = id ?? new Date().getTime().toString();
}
- getOrCreate({ id }: GetOrCreateParams) {
+ get openDialogCount() {
+ return Object.values(this.state.getLatestValue().dialogsById).reduce((count, dialog) => {
+ if (dialog.isOpen) return count + 1;
+ return count;
+ }, 0);
+ }
+
+ getOrCreate({ id }: GetOrCreateDialogParams) {
let dialog = this.state.getLatestValue().dialogsById[id];
if (!dialog) {
dialog = {
@@ -68,7 +73,7 @@ export class DialogManager {
return dialog;
}
- open(params: GetOrCreateParams, closeRest?: boolean) {
+ open(params: GetOrCreateDialogParams, closeRest?: boolean) {
const dialog = this.getOrCreate(params);
if (dialog.isOpen) return;
if (closeRest) {
@@ -77,7 +82,6 @@ export class DialogManager {
this.state.next((current) => ({
...current,
dialogsById: { ...current.dialogsById, [dialog.id]: { ...dialog, isOpen: true } },
- openDialogCount: ++current.openDialogCount,
}));
}
@@ -87,7 +91,6 @@ export class DialogManager {
this.state.next((current) => ({
...current,
dialogsById: { ...current.dialogsById, [dialog.id]: { ...dialog, isOpen: false } },
- openDialogCount: --current.openDialogCount,
}));
}
@@ -95,7 +98,7 @@ export class DialogManager {
Object.values(this.state.getLatestValue().dialogsById).forEach((dialog) => dialog.close());
}
- toggleOpen(params: GetOrCreateParams) {
+ toggleOpen(params: GetOrCreateDialogParams) {
if (this.state.getLatestValue().dialogsById[params.id]?.isOpen) {
this.close(params.id);
} else {
@@ -103,7 +106,7 @@ export class DialogManager {
}
}
- toggleOpenSingle(params: GetOrCreateParams) {
+ toggleOpenSingle(params: GetOrCreateDialogParams) {
if (this.state.getLatestValue().dialogsById[params.id]?.isOpen) {
this.close(params.id);
} else {
@@ -122,9 +125,6 @@ export class DialogManager {
return {
...current,
dialogsById: newDialogs,
- openDialogCount:
- current.openDialogCount &&
- (dialog.isOpen ? current.openDialogCount - 1 : current.openDialogCount),
};
});
}
diff --git a/src/components/Dialog/DialogPortal.tsx b/src/components/Dialog/DialogPortal.tsx
index eb3e912c1..e9bb63de7 100644
--- a/src/components/Dialog/DialogPortal.tsx
+++ b/src/components/Dialog/DialogPortal.tsx
@@ -1,24 +1,11 @@
-import React, { PropsWithChildren, useEffect, useLayoutEffect, useState } from 'react';
+import React, { PropsWithChildren, useLayoutEffect, useState } from 'react';
import { createPortal } from 'react-dom';
-import { useDialogIsOpen } from './hooks';
+import { useDialogIsOpen, useOpenedDialogCount } from './hooks';
import { useDialogManager } from '../../context';
export const DialogPortalDestination = () => {
const { dialogManager } = useDialogManager();
- const [shouldRender, setShouldRender] = useState(
- !!dialogManager.state.getLatestValue().openDialogCount,
- );
-
- useEffect(
- () =>
- dialogManager.state.subscribeWithSelector(
- ({ openDialogCount }) => [openDialogCount],
- ([openDialogCount]) => {
- setShouldRender(openDialogCount > 0);
- },
- ),
- [dialogManager],
- );
+ const openedDialogCount = useOpenedDialogCount();
return (
{
onClick={() => dialogManager.closeAll()}
style={
{
- '--str-chat__dialog-overlay-height': shouldRender ? '100%' : '0',
+ '--str-chat__dialog-overlay-height': openedDialogCount > 0 ? '100%' : '0',
} as React.CSSProperties
}
>
diff --git a/src/components/Dialog/__tests__/DialogsManager.test.js b/src/components/Dialog/__tests__/DialogsManager.test.js
index 23895e8c5..9c86e105e 100644
--- a/src/components/Dialog/__tests__/DialogsManager.test.js
+++ b/src/components/Dialog/__tests__/DialogsManager.test.js
@@ -5,20 +5,20 @@ const dialogId = 'dialogId';
describe('DialogManager', () => {
it('initiates with provided options', () => {
const id = 'XX';
- const dm = new DialogManager({ id });
- expect(dm.id).toBe(id);
+ const dialogManager = new DialogManager({ id });
+ expect(dialogManager.id).toBe(id);
});
it('initiates with default options', () => {
const mockedId = '12345';
const spy = jest.spyOn(Date.prototype, 'getTime').mockReturnValueOnce(mockedId);
- const dm = new DialogManager();
- expect(dm.id).toBe(mockedId);
+ const dialogManager = new DialogManager();
+ expect(dialogManager.id).toBe(mockedId);
spy.mockRestore();
});
it('creates a new closed dialog', () => {
- const dm = new DialogManager();
- expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(0);
- expect(dm.getOrCreate({ id: dialogId })).toMatchObject({
+ const dialogManager = new DialogManager();
+ expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(0);
+ expect(dialogManager.getOrCreate({ id: dialogId })).toMatchObject({
close: expect.any(Function),
id: 'dialogId',
isOpen: false,
@@ -27,27 +27,27 @@ describe('DialogManager', () => {
toggle: expect.any(Function),
toggleSingle: expect.any(Function),
});
- expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(1);
- expect(dm.state.getLatestValue().openDialogCount).toBe(0);
+ expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(1);
+ expect(dialogManager.openDialogCount).toBe(0);
});
it('retrieves an existing dialog', () => {
- const dm = new DialogManager();
- dm.state.next((current) => ({
+ const dialogManager = new DialogManager();
+ dialogManager.state.next((current) => ({
...current,
dialogsById: { ...current.dialogsById, [dialogId]: { id: dialogId, isOpen: true } },
}));
- expect(dm.getOrCreate({ id: dialogId })).toMatchObject({
+ expect(dialogManager.getOrCreate({ id: dialogId })).toMatchObject({
id: 'dialogId',
isOpen: true,
});
- expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(1);
+ expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(1);
});
it('creates a dialog if it does not exist on open', () => {
- const dm = new DialogManager();
- dm.open({ id: dialogId });
- expect(dm.state.getLatestValue().dialogsById[dialogId]).toMatchObject({
+ const dialogManager = new DialogManager();
+ dialogManager.open({ id: dialogId });
+ expect(dialogManager.state.getLatestValue().dialogsById[dialogId]).toMatchObject({
close: expect.any(Function),
id: 'dialogId',
isOpen: true,
@@ -56,92 +56,92 @@ describe('DialogManager', () => {
toggle: expect.any(Function),
toggleSingle: expect.any(Function),
});
- expect(dm.state.getLatestValue().openDialogCount).toBe(1);
+ expect(dialogManager.openDialogCount).toBe(1);
});
it('opens existing dialog', () => {
- const dm = new DialogManager();
- dm.getOrCreate({ id: dialogId });
- dm.open({ id: dialogId });
- expect(dm.state.getLatestValue().dialogsById[dialogId].isOpen).toBeTruthy();
- expect(dm.state.getLatestValue().openDialogCount).toBe(1);
+ const dialogManager = new DialogManager();
+ dialogManager.getOrCreate({ id: dialogId });
+ dialogManager.open({ id: dialogId });
+ expect(dialogManager.state.getLatestValue().dialogsById[dialogId].isOpen).toBeTruthy();
+ expect(dialogManager.openDialogCount).toBe(1);
});
it('does not open already open dialog', () => {
- const dm = new DialogManager();
- dm.getOrCreate({ id: dialogId });
- dm.open({ id: dialogId });
- dm.open({ id: dialogId });
- expect(dm.state.getLatestValue().openDialogCount).toBe(1);
+ const dialogManager = new DialogManager();
+ dialogManager.getOrCreate({ id: dialogId });
+ dialogManager.open({ id: dialogId });
+ dialogManager.open({ id: dialogId });
+ expect(dialogManager.openDialogCount).toBe(1);
});
it('closes all other dialogsById before opening the target', () => {
- const dm = new DialogManager();
- dm.open({ id: 'xxx' });
- dm.open({ id: 'yyy' });
- expect(dm.state.getLatestValue().openDialogCount).toBe(2);
- dm.open({ id: dialogId }, true);
- const dialogs = dm.state.getLatestValue().dialogsById;
+ const dialogManager = new DialogManager();
+ dialogManager.open({ id: 'xxx' });
+ dialogManager.open({ id: 'yyy' });
+ expect(dialogManager.openDialogCount).toBe(2);
+ dialogManager.open({ id: dialogId }, true);
+ const dialogs = dialogManager.state.getLatestValue().dialogsById;
expect(dialogs.xxx.isOpen).toBeFalsy();
expect(dialogs.yyy.isOpen).toBeFalsy();
- expect(dm.state.getLatestValue().dialogsById[dialogId].isOpen).toBeTruthy();
- expect(dm.state.getLatestValue().openDialogCount).toBe(1);
+ expect(dialogManager.state.getLatestValue().dialogsById[dialogId].isOpen).toBeTruthy();
+ expect(dialogManager.openDialogCount).toBe(1);
});
it('closes opened dialog', () => {
- const dm = new DialogManager();
- dm.open({ id: dialogId });
- dm.close(dialogId);
- expect(dm.state.getLatestValue().dialogsById[dialogId].isOpen).toBeFalsy();
- expect(dm.state.getLatestValue().openDialogCount).toBe(0);
+ const dialogManager = new DialogManager();
+ dialogManager.open({ id: dialogId });
+ dialogManager.close(dialogId);
+ expect(dialogManager.state.getLatestValue().dialogsById[dialogId].isOpen).toBeFalsy();
+ expect(dialogManager.openDialogCount).toBe(0);
});
it('does not close already closed dialog', () => {
- const dm = new DialogManager();
- dm.open({ id: 'xxx' });
- dm.open({ id: dialogId });
- dm.close(dialogId);
- dm.close(dialogId);
- expect(dm.state.getLatestValue().openDialogCount).toBe(1);
+ const dialogManager = new DialogManager();
+ dialogManager.open({ id: 'xxx' });
+ dialogManager.open({ id: dialogId });
+ dialogManager.close(dialogId);
+ dialogManager.close(dialogId);
+ expect(dialogManager.openDialogCount).toBe(1);
});
it('toggles the open state of a dialog', () => {
- const dm = new DialogManager();
- dm.open({ id: 'xxx' });
- dm.open({ id: 'yyy' });
- dm.toggleOpen({ id: dialogId });
- expect(dm.state.getLatestValue().openDialogCount).toBe(3);
- dm.toggleOpen({ id: dialogId });
- expect(dm.state.getLatestValue().openDialogCount).toBe(2);
+ const dialogManager = new DialogManager();
+ dialogManager.open({ id: 'xxx' });
+ dialogManager.open({ id: 'yyy' });
+ dialogManager.toggleOpen({ id: dialogId });
+ expect(dialogManager.openDialogCount).toBe(3);
+ dialogManager.toggleOpen({ id: dialogId });
+ expect(dialogManager.openDialogCount).toBe(2);
});
it('keeps single opened dialog when the toggling open dialog state', () => {
- const dm = new DialogManager();
+ const dialogManager = new DialogManager();
- dm.open({ id: 'xxx' });
- dm.open({ id: 'yyy' });
- dm.toggleOpenSingle({ id: dialogId });
- expect(dm.state.getLatestValue().openDialogCount).toBe(1);
+ dialogManager.open({ id: 'xxx' });
+ dialogManager.open({ id: 'yyy' });
+ dialogManager.toggleOpenSingle({ id: dialogId });
+ expect(dialogManager.openDialogCount).toBe(1);
- dm.toggleOpenSingle({ id: dialogId });
- expect(dm.state.getLatestValue().openDialogCount).toBe(0);
+ dialogManager.toggleOpenSingle({ id: dialogId });
+ expect(dialogManager.openDialogCount).toBe(0);
});
it('removes a dialog', () => {
- const dm = new DialogManager();
- dm.getOrCreate({ id: dialogId });
- dm.open({ id: dialogId });
- dm.remove(dialogId);
- expect(dm.state.getLatestValue().openDialogCount).toBe(0);
- expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(0);
+ const dialogManager = new DialogManager();
+ dialogManager.getOrCreate({ id: dialogId });
+ dialogManager.open({ id: dialogId });
+ dialogManager.remove(dialogId);
+ expect(dialogManager.openDialogCount).toBe(0);
+ expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(0);
});
it('handles attempt to remove non-existent dialog', () => {
- const dm = new DialogManager();
- dm.getOrCreate({ id: dialogId });
- dm.open({ id: dialogId });
- dm.remove('xxx');
- expect(dm.state.getLatestValue().openDialogCount).toBe(1);
- expect(Object.keys(dm.state.getLatestValue().dialogsById)).toHaveLength(1);
+ const dialogManager = new DialogManager();
+ dialogManager.getOrCreate({ id: dialogId });
+ dialogManager.open({ id: dialogId });
+ dialogManager.remove('xxx');
+ expect(dialogManager.openDialogCount).toBe(1);
+ expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(1);
});
});
diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts
index f3ab3ab39..9fc293b12 100644
--- a/src/components/Dialog/hooks/useDialog.ts
+++ b/src/components/Dialog/hooks/useDialog.ts
@@ -1,8 +1,10 @@
-import { useEffect, useState } from 'react';
-import { useDialogManager } from '../../../context/DialogManagerContext';
-import type { GetOrCreateParams } from '../DialogManager';
+import { useCallback, useEffect } from 'react';
+import { useDialogManager } from '../../../context';
+import { useStateStore } from '../../../store';
-export const useDialog = ({ id }: GetOrCreateParams) => {
+import type { DialogManagerState, GetOrCreateDialogParams } from '../DialogManager';
+
+export const useDialog = ({ id }: GetOrCreateDialogParams) => {
const { dialogManager } = useDialogManager();
useEffect(
@@ -15,21 +17,26 @@ export const useDialog = ({ id }: GetOrCreateParams) => {
return dialogManager.getOrCreate({ id });
};
-export const useDialogIsOpen = (id: string, source?: string) => {
+export const useDialogIsOpen = (id: string) => {
const { dialogManager } = useDialogManager();
- const [open, setOpen] = useState(false);
-
- useEffect(
- () =>
- dialogManager.state.subscribeWithSelector(
- ({ dialogsById }) => [!!dialogsById[id]?.isOpen],
- ([isOpen]) => {
- setOpen(isOpen);
- },
- // id,
- ),
- [dialogManager, id, source],
+ const dialogIsOpenSelector = useCallback(
+ ({ dialogsById }: DialogManagerState) => [!!dialogsById[id]?.isOpen],
+ [id],
);
+ return useStateStore(dialogManager.state, dialogIsOpenSelector)[0];
+};
+
+const openedDialogCountSelector = (nextValue: DialogManagerState) => [
+ Object.values(nextValue.dialogsById).reduce((count, dialog) => {
+ if (dialog.isOpen) return count + 1;
+ return count;
+ }, 0),
+];
- return open;
+export const useOpenedDialogCount = () => {
+ const { dialogManager } = useDialogManager();
+ return useStateStore(
+ dialogManager.state,
+ openedDialogCountSelector,
+ )[0];
};
From a344533da7c88c2997dfcf0d9312d42a15f99a0e Mon Sep 17 00:00:00 2001
From: MartinCupela <32706194+MartinCupela@users.noreply.github.com>
Date: Wed, 11 Sep 2024 12:36:36 +0200
Subject: [PATCH 24/29] refactor: apply suggestions about declaring state
selector
Co-authored-by: Anton Arnautov <43254280+arnautov-anton@users.noreply.github.com>
---
src/components/Dialog/hooks/useDialog.ts | 22 ++++++++++------------
1 file changed, 10 insertions(+), 12 deletions(-)
diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts
index 9fc293b12..d0387ab9c 100644
--- a/src/components/Dialog/hooks/useDialog.ts
+++ b/src/components/Dialog/hooks/useDialog.ts
@@ -20,23 +20,21 @@ export const useDialog = ({ id }: GetOrCreateDialogParams) => {
export const useDialogIsOpen = (id: string) => {
const { dialogManager } = useDialogManager();
const dialogIsOpenSelector = useCallback(
- ({ dialogsById }: DialogManagerState) => [!!dialogsById[id]?.isOpen],
+ ({ dialogsById }: DialogManagerState) => [!!dialogsById[id]?.isOpen] as const,
[id],
);
- return useStateStore(dialogManager.state, dialogIsOpenSelector)[0];
+ return useStateStore(dialogManager.state, dialogIsOpenSelector)[0];
};
-const openedDialogCountSelector = (nextValue: DialogManagerState) => [
- Object.values(nextValue.dialogsById).reduce((count, dialog) => {
- if (dialog.isOpen) return count + 1;
- return count;
- }, 0),
-];
+const openedDialogCountSelector = (nextValue: DialogManagerState) =>
+ [
+ Object.values(nextValue.dialogsById).reduce((count, dialog) => {
+ if (dialog.isOpen) return count + 1;
+ return count;
+ }, 0),
+ ] as const;
export const useOpenedDialogCount = () => {
const { dialogManager } = useDialogManager();
- return useStateStore(
- dialogManager.state,
- openedDialogCountSelector,
- )[0];
+ return useStateStore(dialogManager.state, openedDialogCountSelector)[0];
};
From a212d2779514c917638799b76ee5cf61933863e2 Mon Sep 17 00:00:00 2001
From: martincupela
Date: Wed, 11 Sep 2024 15:26:17 +0200
Subject: [PATCH 25/29] fix: remove unsupported onClick prop from
ReactionListProps
---
src/components/Message/__tests__/MessageOptions.test.js | 8 +-------
src/components/Message/__tests__/MessageText.test.js | 1 -
src/components/Reactions/ReactionsList.tsx | 4 ----
3 files changed, 1 insertion(+), 12 deletions(-)
diff --git a/src/components/Message/__tests__/MessageOptions.test.js b/src/components/Message/__tests__/MessageOptions.test.js
index 1b2162fa0..b744bc2ad 100644
--- a/src/components/Message/__tests__/MessageOptions.test.js
+++ b/src/components/Message/__tests__/MessageOptions.test.js
@@ -34,7 +34,6 @@ const defaultMessageProps = {
initialMessage: false,
message: generateMessage(),
messageActions: Object.keys(MESSAGE_ACTIONS),
- onReactionListClick: () => {},
threadList: false,
};
const defaultOptionsProps = {};
@@ -70,12 +69,7 @@ async function renderMessageOptions({
value={{
Attachment,
// eslint-disable-next-line react/display-name
- Message: () => (
-
- ),
+ Message: () => ,
reactionOptions: defaultReactionOptions,
}}
>
diff --git a/src/components/Message/__tests__/MessageText.test.js b/src/components/Message/__tests__/MessageText.test.js
index 8171eadac..4f561c2a9 100644
--- a/src/components/Message/__tests__/MessageText.test.js
+++ b/src/components/Message/__tests__/MessageText.test.js
@@ -43,7 +43,6 @@ const onMentionsClickMock = jest.fn();
const defaultProps = {
initialMessage: false,
message: generateMessage(),
- onReactionListClick: () => {},
threadList: false,
};
diff --git a/src/components/Reactions/ReactionsList.tsx b/src/components/Reactions/ReactionsList.tsx
index d5974854a..c03025e44 100644
--- a/src/components/Reactions/ReactionsList.tsx
+++ b/src/components/Reactions/ReactionsList.tsx
@@ -4,8 +4,6 @@ import clsx from 'clsx';
import type { ReactionGroupResponse, ReactionResponse } from 'stream-chat';
import { useProcessReactions } from './hooks/useProcessReactions';
-
-import type { ReactEventHandler } from '../Message/types';
import type { DefaultStreamChatGenerics } from '../../types/types';
import type { ReactionOptions } from './reactionOptions';
import type { ReactionDetailsComparator, ReactionsComparator, ReactionType } from './types';
@@ -18,8 +16,6 @@ export type ReactionsListProps<
> = Partial<
Pick, 'handleFetchReactions' | 'reactionDetailsSort'>
> & {
- /** Custom on click handler for an individual reaction, defaults to `onReactionListClick` from the `MessageContext` */
- onClick?: ReactEventHandler;
/** An array of the own reaction objects to distinguish own reactions visually */
own_reactions?: ReactionResponse[];
/**
From 993200133cbaf3420cddd30bf333ff3385bb4773 Mon Sep 17 00:00:00 2001
From: martincupela
Date: Wed, 11 Sep 2024 15:27:42 +0200
Subject: [PATCH 26/29] docs: remove references to removed props
---
.../components/contexts/message-context.mdx | 24 -------------------
.../message-components/message-ui.mdx | 24 -------------------
.../message-components/reactions.mdx | 8 -------
.../message-components/ui-components.mdx | 16 -------------
4 files changed, 72 deletions(-)
diff --git a/docusaurus/docs/React/components/contexts/message-context.mdx b/docusaurus/docs/React/components/contexts/message-context.mdx
index 35cce3268..d18a8d436 100644
--- a/docusaurus/docs/React/components/contexts/message-context.mdx
+++ b/docusaurus/docs/React/components/contexts/message-context.mdx
@@ -304,14 +304,6 @@ Function that runs on hover of an @mention in a message.
| ----------------------------------------------------------- |
| (event: React.BaseSyntheticEvent) => Promise \| void |
-### onReactionListClick
-
-Function that runs on click of the reactions list component.
-
-| Type |
-| ----------------------------------------------------------- |
-| (event: React.BaseSyntheticEvent) => Promise \| void |
-
### onUserClick
Function that runs on click of a user avatar.
@@ -336,14 +328,6 @@ The user roles allowed to pin messages in various channel types (deprecated in f
| ------ | ------------------------------------------------------------------------- |
| object | |
-### reactionSelectorRef
-
-Ref to be placed on the reaction selector component.
-
-| Type |
-| --------------------------------------- |
-| React.MutableRefObject |
-
### readBy
An array of users that have read the current message.
@@ -368,14 +352,6 @@ Function to toggle the editing state on a message.
| ----------------------------------------------------------- |
| (event: React.BaseSyntheticEvent) => Promise \| void |
-### showDetailedReactions
-
-When true, show the reactions list component.
-
-| Type |
-| ------- |
-| boolean |
-
### reactionDetailsSort
Sort options to provide to a reactions query. Affects the order of reacted users in the default reactions modal.
diff --git a/docusaurus/docs/React/components/message-components/message-ui.mdx b/docusaurus/docs/React/components/message-components/message-ui.mdx
index e8ad602d3..7a6bdd4ea 100644
--- a/docusaurus/docs/React/components/message-components/message-ui.mdx
+++ b/docusaurus/docs/React/components/message-components/message-ui.mdx
@@ -397,14 +397,6 @@ Function that runs on hover of an @mention in a message (overrides the function
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| (event: React.BaseSyntheticEvent) => Promise \| void | [MessageContextValue['onMentionsHoverMessage']](../contexts/channel-action-context.mdx#onmentionshovermessage) |
-### onReactionListClick
-
-Function that runs on click of the reactions list component (overrides the function stored in `MessageContext`).
-
-| Type | Default |
-| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
-| (event: React.BaseSyntheticEvent) => Promise \| void | [MessageContextValue['onReactionListClick']](../contexts/channel-action-context.mdx#onreactionlistclick) |
-
### onUserClick
Function that runs on click of a user avatar (overrides the function stored in `MessageContext`).
@@ -429,14 +421,6 @@ The user roles allowed to pin messages in various channel types (deprecated in f
| ------ | -------------------------------------------------------------------------------------------------------------------- |
| object | [defaultPinPermissions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/utils.tsx) |
-### reactionSelectorRef
-
-Ref to be placed on the reaction selector component (overrides the ref stored in `MessageContext`).
-
-| Type |
-| --------------------------------------- |
-| React.MutableRefObject |
-
### readBy
An array of users that have read the current message (overrides the value stored in `MessageContext`).
@@ -461,14 +445,6 @@ Function to toggle the editing state on a message (overrides the function stored
| ----------------------------------------------------------- |
| (event: React.BaseSyntheticEvent) => Promise \| void |
-### showDetailedReactions
-
-When true, show the reactions list component (overrides the value stored in `MessageContext`).
-
-| Type |
-| ------- |
-| boolean |
-
### threadList
If true, indicates that the current `MessageList` component is part of a `Thread` (overrides the value stored in `MessageContext`).
diff --git a/docusaurus/docs/React/components/message-components/reactions.mdx b/docusaurus/docs/React/components/message-components/reactions.mdx
index 1f3a79613..68cade800 100644
--- a/docusaurus/docs/React/components/message-components/reactions.mdx
+++ b/docusaurus/docs/React/components/message-components/reactions.mdx
@@ -151,14 +151,6 @@ const MyCustomReactionsList = (props) => {
};
```
-### onClick
-
-Custom on click handler for an individual reaction in the list (overrides the function coming from `MessageContext`).
-
-| Type | Default |
-| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
-| (event: React.BaseSyntheticEvent) => Promise \| void | [MessageContextValue['onReactionListClick']](../contexts/message-context.mdx#onreactionlistclick) |
-
### own_reactions
An array of the own reaction objects to distinguish own reactions visually (overrides `message.own_reactions` from `MessageContext`).
diff --git a/docusaurus/docs/React/components/message-components/ui-components.mdx b/docusaurus/docs/React/components/message-components/ui-components.mdx
index dd88e3c80..ff1a2e850 100644
--- a/docusaurus/docs/React/components/message-components/ui-components.mdx
+++ b/docusaurus/docs/React/components/message-components/ui-components.mdx
@@ -126,14 +126,6 @@ The `StreamChat` message object, which provides necessary data to the underlying
| ------ |
| object |
-### messageWrapperRef
-
-React mutable ref placed on the message root `div`. It is forwarded by `MessageOptions` down to `MessageActions` ([see the example](../../guides/theming/message-ui.mdx)).
-
-| Type |
-| -------------------------------- |
-| React.RefObject |
-
### mine
Function that returns whether the message was sent by the connected user.
@@ -178,14 +170,6 @@ Function that opens a [`Thread`](../core-components/thread.mdx) on a message (ov
| ----------------------------------------------------------- |
| (event: React.BaseSyntheticEvent) => Promise \| void |
-### messageWrapperRef
-
-React mutable ref that can be placed on the message root `div`. `MessageOptions` component forwards this prop to [`MessageActions`](#messageactions-props) component ([see the example](../../guides/theming/message-ui.mdx)).
-
-| Type |
-| -------------------------------- |
-| React.RefObject |
-
### ReactionIcon
Custom component rendering the icon used in a message options button invoking reactions selector for a given message.
From 7b32ea366511e250c629120d6f450c77256c85e1 Mon Sep 17 00:00:00 2001
From: martincupela
Date: Wed, 11 Sep 2024 15:28:27 +0200
Subject: [PATCH 27/29] docs: add dialog management guide and migration guide
---
.../docs/React/guides/dialog-management.mdx | 108 ++++++++++++++++++
.../React/release-guides/upgrade-to-v12.mdx | 32 ++++++
docusaurus/sidebars-react.json | 3 +-
3 files changed, 142 insertions(+), 1 deletion(-)
create mode 100644 docusaurus/docs/React/guides/dialog-management.mdx
diff --git a/docusaurus/docs/React/guides/dialog-management.mdx b/docusaurus/docs/React/guides/dialog-management.mdx
new file mode 100644
index 000000000..8031c6aad
--- /dev/null
+++ b/docusaurus/docs/React/guides/dialog-management.mdx
@@ -0,0 +1,108 @@
+---
+id: dialog-management
+title: Dialog Management
+---
+
+This article presents the API the integrators can use to toggle display dialogs in their UIs. The default components that are displayed as dialogs are:
+
+- `ReactionSelector` - allows users to post reactions / emojis to a message
+- `MessageActionsBox` - allows user to select from a list of permitted message actions
+
+The dialog management following this guide is enabled within `MessageList` and `VirtualizedMessageList`.
+
+## Setup dialog display
+
+There are two actors in the play. The first one is the component that requests the dialog to be closed or open and the other is the component that renders the dialog. We will start with demonstrating how to properly render a component in a dialog.
+
+### Rendering a dialog
+
+Component we want to be rendered as a floating dialog should be wrapped inside `DialogAnchor`:
+
+```tsx
+import React, { ElementRef, useRef } from 'react';
+import { DialogAnchor } from 'stream-chat-react';
+
+import { ComponentToDisplayOnDialog } from './ComponentToDisplayOnDialog';
+import { generateUniqueId } from './generateUniqueId';
+
+const Container = () => {
+ // DialogAnchor needs a reference to the element that will toggle the open state. Based on this reference the dialog positioning is calculated
+ const buttonRef = useRef>(null);
+ // providing the dialog is necessary for the dialog to be retrieved from anywhere in the DialogManagerProviderContext
+ const dialogId = generateUniqueId();
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
+```
+
+### Controlling a dialog's display
+
+The dialog display is controlled via Dialog API. You can access the API via `useDialog()` hook.
+
+```tsx
+import React, { ElementRef, useRef } from 'react';
+import { DialogAnchor, useDialog, useDialogIsOpen } from 'stream-chat-react';
+
+import { ComponentToDisplayOnDialog } from './ComponentToDisplayOnDialog';
+import { generateUniqueId } from './generateUniqueId';
+
+const Container = () => {
+ const buttonRef = useRef>(null);
+ const dialogId = generateUniqueId();
+ // access the dialog controller which provides the dialog API
+ const dialog = useDialog({ id: dialogId });
+ // subscribe to dialog open state changes
+ const dialogIsOpen = useDialogIsOpen(dialogId);
+
+ return (
+ <>
+
+
+
+
+ Toggle
+
+ >
+ );
+};
+```
+
+### Dialog API
+
+Dialog can be controlled via `Dialog` object retrieved using `useDialog()` hook. The hook returns an object with the following API:
+
+- `dialog.open()` - opens the dialog
+- `dialog.close()` - closes the dialog
+- `dialog.toggleOpen()` - flips the dialog open state and does not close any other dialog that could be open
+- `dialog.toggleOpenSingle()` - flips the open state and does close any other dialog that could be open
+- `dialog.remove()` - removes the dialog object reference from the state (primarily for cleanup purposes)
+
+Every `Dialog` object carries its own `id` and `isOpen` flag.
+
+### Dialog utility hooks
+
+There are the following utility hooks that can be used to subscribe to state changes or access a given dialog:
+
+- `useDialogIsOpen(id: string)` - allows to observe the open state of a particular `Dialog` instance
+- `useDialog({ id }: GetOrCreateDialogParams)` - retrieves a dialog object that exposes API to manage it
+- `useOpenedDialogCount()` - allows to observe changes in the open dialog count
+
+### Custom dialog management context
+
+Those who would like to render dialogs outside the `MessageList` and `VirtualizedMessageList`, will need to create a dialog management context using `DialogManagerProvider`.
+
+```tsx
+import { DialogManagerProvider } from 'stream-chat-react';
+
+const Container = () => {
+ return ;
+};
+```
+
+Now the children of `DialogAnchor` will be anchored to the parent `DialogManagerProvider`.
diff --git a/docusaurus/docs/React/release-guides/upgrade-to-v12.mdx b/docusaurus/docs/React/release-guides/upgrade-to-v12.mdx
index f5d52acbc..0a136d838 100644
--- a/docusaurus/docs/React/release-guides/upgrade-to-v12.mdx
+++ b/docusaurus/docs/React/release-guides/upgrade-to-v12.mdx
@@ -117,6 +117,38 @@ import { encodeToMp3 } from 'stream-chat-react/mp3-encoder';
:::
+## Unified dialog management
+
+Dialogs will be managed centrally. At the moment, this applies to display of `ReactionSelector` and `MessageActionsBox`. They will be displayed on a transparent overlay that prevents users from opening other dialogs in the message list. Once an option from a dialog is selected or the overlay is clicked, the dialog will disappear. This adjust brings new API and removes some properties from `MessageContextValue`.
+
+### Removed properties from MessageContextValue
+
+- `isReactionEnabled` - served to signal the permission to send reactions by the current user in a given channel. With the current permissions implementation, the permission can be determined by doing the following:
+
+```
+import { useMessageContext } from 'stream-chat-react';
+
+const { getMessageActions } = useMessageContext();
+const messageActions = getMessageActions();
+const canReact = messageActions.includes(MESSAGE_ACTIONS.react);
+```
+
+- `onReactionListClick` - handler function that toggled the open state of `ReactionSelector` represented by another removed value - `showDetailedReactions`
+- `showDetailedReactions` - flag used to decide, whether the reaction selector should be shown or not
+- `reactionSelectorRef` - ref to the root of the reaction selector component (served to control the display of the component)
+
+Also prop `messageWrapperRef` was removed as part of the change from `MessageOptions` and `MessageActions` props.
+
+On the other hand, the `Message` prop (configuration parameter) `closeReactionSelectorOnClick` is now available in the `MessageContextValue`.
+
+:::important
+If you used any of these values in your customizations, please make sure to adjust your implementation according to the newly recommended use of Dialog API in [Dialog management guide](../../guides/dialog-management).
+:::
+
+### New dialog management API
+
+To learn about the new API, please, take a look at our [Dialog management guide](../../guides/dialog-management).
+
## EmojiPickerIcon extraction to emojis plugin
The default `EmojiPickerIcon` has been moved to emojis plugin from which we already import `EmojiPicker` component.
diff --git a/docusaurus/sidebars-react.json b/docusaurus/sidebars-react.json
index aaf798de7..548c89099 100644
--- a/docusaurus/sidebars-react.json
+++ b/docusaurus/sidebars-react.json
@@ -140,7 +140,8 @@
"guides/channel_read_state",
"guides/video-integration/video-integration-stream",
"guides/sdk-state-management",
- "guides/date-time-formatting"
+ "guides/date-time-formatting",
+ "guides/dialog-management"
]
},
{ "Release Guides": ["release-guides/upgrade-to-v12", "release-guides/upgrade-to-v11", "release-guides/upgrade-to-v10"] },
From f649140b70bc6e31c9214f4401a68424fbff6888 Mon Sep 17 00:00:00 2001
From: martincupela
Date: Fri, 13 Sep 2024 11:02:04 +0200
Subject: [PATCH 28/29] chore(docs): bump stream-chat-css to v5.0.0-rc.6
---
package.json | 2 +-
yarn.lock | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/package.json b/package.json
index a9b3d6f05..a2152cbc9 100644
--- a/package.json
+++ b/package.json
@@ -171,7 +171,7 @@
"@semantic-release/changelog": "^6.0.2",
"@semantic-release/git": "^10.0.1",
"@stream-io/rollup-plugin-node-builtins": "^2.1.5",
- "@stream-io/stream-chat-css": "^5.0.0-rc.5",
+ "@stream-io/stream-chat-css": "5.0.0-rc.6",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^13.1.1",
"@testing-library/react-hooks": "^8.0.0",
diff --git a/yarn.lock b/yarn.lock
index 229ec0df5..92b9510f8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2356,10 +2356,10 @@
crypto-browserify "^3.11.0"
process-es6 "^0.11.2"
-"@stream-io/stream-chat-css@^5.0.0-rc.5":
- version "5.0.0-rc.5"
- resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-5.0.0-rc.5.tgz#889218fc9c604b12d4b8d5895a7c96668d4b78fc"
- integrity sha512-1NfgoJE5PC/i4aVspIsMaSbvh8rphpilAv6+zlBOCVQL/AAhSFt8QdHUGSTeqwzI7p6waiFk0pQ2bSWKTUpuFA==
+"@stream-io/stream-chat-css@5.0.0-rc.6":
+ version "5.0.0-rc.6"
+ resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-5.0.0-rc.6.tgz#8ad9f7290150d10c4135ec3205e83569a0bce95d"
+ integrity sha512-tT+9glFTdA0ayyhFvpBNfcBi4wZGcr1FSiwS2aNYJrWFE0XpM4aXgq8h5bWha3mOBcQErTDHoUxRw0D/JOt69A==
"@stream-io/transliterate@^1.5.5":
version "1.5.5"
From 7a67c034f7e43c9a1e641bbae95d2c10d16a4f88 Mon Sep 17 00:00:00 2001
From: martincupela
Date: Mon, 16 Sep 2024 17:38:51 +0200
Subject: [PATCH 29/29] refactor: merge Dialog.toggleOpen and
Dialog.toggleOpenSingle into Dialog.toggle
---
.../docs/React/guides/dialog-management.mdx | 3 +-
src/components/Dialog/DialogManager.ts | 32 +++++++++----------
.../Dialog/__tests__/DialogsManager.test.js | 10 +++---
.../MessageActions/MessageActions.tsx | 2 +-
.../Reactions/ReactionSelectorWithButton.tsx | 2 +-
5 files changed, 22 insertions(+), 27 deletions(-)
diff --git a/docusaurus/docs/React/guides/dialog-management.mdx b/docusaurus/docs/React/guides/dialog-management.mdx
index 8031c6aad..f2c500115 100644
--- a/docusaurus/docs/React/guides/dialog-management.mdx
+++ b/docusaurus/docs/React/guides/dialog-management.mdx
@@ -79,8 +79,7 @@ Dialog can be controlled via `Dialog` object retrieved using `useDialog()` hook.
- `dialog.open()` - opens the dialog
- `dialog.close()` - closes the dialog
-- `dialog.toggleOpen()` - flips the dialog open state and does not close any other dialog that could be open
-- `dialog.toggleOpenSingle()` - flips the open state and does close any other dialog that could be open
+- `dialog.toggle()` - toggles the dialog open state. Accepts boolean argument `closeAll`. If enabled closes any other dialog that would be open.
- `dialog.remove()` - removes the dialog object reference from the state (primarily for cleanup purposes)
Every `Dialog` object carries its own `id` and `isOpen` flag.
diff --git a/src/components/Dialog/DialogManager.ts b/src/components/Dialog/DialogManager.ts
index 320519eef..503adbcf2 100644
--- a/src/components/Dialog/DialogManager.ts
+++ b/src/components/Dialog/DialogManager.ts
@@ -12,8 +12,7 @@ export type Dialog = {
isOpen: boolean | undefined;
open: (zIndex?: number) => void;
remove: () => void;
- toggle: () => void;
- toggleSingle: () => void;
+ toggle: (closeAll?: boolean) => void;
};
export type DialogManagerOptions = {
@@ -26,6 +25,16 @@ export type DialogManagerState = {
dialogsById: Dialogs;
};
+/**
+ * Keeps a map of Dialog objects.
+ * Dialog can be controlled via `Dialog` object retrieved using `useDialog()` hook.
+ * The hook returns an object with the following API:
+ *
+ * - `dialog.open()` - opens the dialog
+ * - `dialog.close()` - closes the dialog
+ * - `dialog.toggle()` - toggles the dialog open state. Accepts boolean argument closeAll. If enabled closes any other dialog that would be open.
+ * - `dialog.remove()` - removes the dialog object reference from the state (primarily for cleanup purposes)
+ */
export class DialogManager {
id: string;
state = new StateStore({
@@ -58,11 +67,8 @@ export class DialogManager {
remove: () => {
this.remove(id);
},
- toggle: () => {
- this.toggleOpen({ id });
- },
- toggleSingle: () => {
- this.toggleOpenSingle({ id });
+ toggle: (closeAll = false) => {
+ this.toggle({ id }, closeAll);
},
};
this.state.next((current) => ({
@@ -98,19 +104,11 @@ export class DialogManager {
Object.values(this.state.getLatestValue().dialogsById).forEach((dialog) => dialog.close());
}
- toggleOpen(params: GetOrCreateDialogParams) {
- if (this.state.getLatestValue().dialogsById[params.id]?.isOpen) {
- this.close(params.id);
- } else {
- this.open(params);
- }
- }
-
- toggleOpenSingle(params: GetOrCreateDialogParams) {
+ toggle(params: GetOrCreateDialogParams, closeAll = false) {
if (this.state.getLatestValue().dialogsById[params.id]?.isOpen) {
this.close(params.id);
} else {
- this.open(params, true);
+ this.open(params, closeAll);
}
}
diff --git a/src/components/Dialog/__tests__/DialogsManager.test.js b/src/components/Dialog/__tests__/DialogsManager.test.js
index 9c86e105e..f27f4d846 100644
--- a/src/components/Dialog/__tests__/DialogsManager.test.js
+++ b/src/components/Dialog/__tests__/DialogsManager.test.js
@@ -25,7 +25,6 @@ describe('DialogManager', () => {
open: expect.any(Function),
remove: expect.any(Function),
toggle: expect.any(Function),
- toggleSingle: expect.any(Function),
});
expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(1);
expect(dialogManager.openDialogCount).toBe(0);
@@ -54,7 +53,6 @@ describe('DialogManager', () => {
open: expect.any(Function),
remove: expect.any(Function),
toggle: expect.any(Function),
- toggleSingle: expect.any(Function),
});
expect(dialogManager.openDialogCount).toBe(1);
});
@@ -109,9 +107,9 @@ describe('DialogManager', () => {
const dialogManager = new DialogManager();
dialogManager.open({ id: 'xxx' });
dialogManager.open({ id: 'yyy' });
- dialogManager.toggleOpen({ id: dialogId });
+ dialogManager.toggle({ id: dialogId });
expect(dialogManager.openDialogCount).toBe(3);
- dialogManager.toggleOpen({ id: dialogId });
+ dialogManager.toggle({ id: dialogId });
expect(dialogManager.openDialogCount).toBe(2);
});
@@ -120,10 +118,10 @@ describe('DialogManager', () => {
dialogManager.open({ id: 'xxx' });
dialogManager.open({ id: 'yyy' });
- dialogManager.toggleOpenSingle({ id: dialogId });
+ dialogManager.toggle({ id: dialogId }, true);
expect(dialogManager.openDialogCount).toBe(1);
- dialogManager.toggleOpenSingle({ id: dialogId });
+ dialogManager.toggle({ id: dialogId }, true);
expect(dialogManager.openDialogCount).toBe(0);
});
diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx
index aa27f68d1..02fe9be56 100644
--- a/src/components/MessageActions/MessageActions.tsx
+++ b/src/components/MessageActions/MessageActions.tsx
@@ -106,7 +106,7 @@ export const MessageActions = <
dialog?.toggle()}
ref={buttonRef}
>