Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add centralized dialog management
Browse files Browse the repository at this point in the history
MartinCupela committed Sep 4, 2024
1 parent eb0d6d4 commit 9751e15
Showing 13 changed files with 575 additions and 223 deletions.
39 changes: 21 additions & 18 deletions src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
@@ -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<StreamChatGenerics> = useMemo(
const componentContextValue = useMemo<ComponentContextValue<StreamChatGenerics>>(
() => ({
Attachment: props.Attachment || DefaultAttachment,
AttachmentPreviewList: props.AttachmentPreviewList,
@@ -1329,20 +1330,22 @@ const ChannelInner = <

return (
<div className={clsx(className, windowsEmojiClass)}>
<ChannelStateProvider value={channelStateContextValue}>
<ChannelActionProvider value={channelActionContextValue}>
<ComponentProvider value={componentContextValue}>
<TypingProvider value={typingContextValue}>
<div className={`${chatContainerClass}`}>
{dragAndDropWindow && (
<DropzoneProvider {...optionalMessageInputProps}>{children}</DropzoneProvider>
)}
{!dragAndDropWindow && <>{children}</>}
</div>
</TypingProvider>
</ComponentProvider>
</ChannelActionProvider>
</ChannelStateProvider>
<DialogsManagerProvider>
<ChannelStateProvider value={channelStateContextValue}>
<ChannelActionProvider value={channelActionContextValue}>
<ComponentProvider value={componentContextValue}>
<TypingProvider value={typingContextValue}>
<div className={`${chatContainerClass}`}>
{dragAndDropWindow && (
<DropzoneProvider {...optionalMessageInputProps}>{children}</DropzoneProvider>
)}
{!dragAndDropWindow && <>{children}</>}
</div>
</TypingProvider>
</ComponentProvider>
</ChannelActionProvider>
</ChannelStateProvider>
</DialogsManagerProvider>
</div>
);
};
79 changes: 79 additions & 0 deletions src/components/Dialog/DialogAnchor.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends HTMLElement>({
open,
placement,
referenceElement,
}: DialogAnchorOptions) {
const popperElementRef = useRef<T>(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<Partial<DialogAnchorOptions>> & {
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<HTMLDivElement>({
open,
placement,
referenceElement,
});

return (
<DialogPortalEntry dialogId={id}>
<div
{...attributes.popper}
className={className}
ref={popperElementRef}
style={styles.popper}
>
{children}
</div>
</DialogPortalEntry>
);
};
62 changes: 62 additions & 0 deletions src/components/Dialog/DialogPortal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div
className='str-chat__dialog-overlay'
onClick={() => dialogsManager.closeAll()}
style={{
height: '100%',
inset: '0',
overflow: 'hidden',
position: 'absolute',
width: '100%',
zIndex: shouldRender ? '2' : '-1',
}}
>
<div data-str-chat__portal-id={dialogsManager.id} />
</div>
</>
);
};

type DialogPortalEntryProps = {
dialogId: string;
};

export const DialogPortalEntry = ({
children,
dialogId,
}: PropsWithChildren<DialogPortalEntryProps>) => {
const { dialogsManager } = useDialogsManager();
const dialogIsOpen = useDialogIsOpen(dialogId);
const [portalDestination, setPortalDestination] = useState<Element | null>(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);
};
178 changes: 178 additions & 0 deletions src/components/Dialog/DialogsManager.ts
Original file line number Diff line number Diff line change
@@ -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<DialogId, Dialog> = {};
private dialogEventListeners: Record<
DialogId,
Partial<Record<DialogEvent['type'], DialogEventHandler[]>>
> = {};
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];
}
}
}
1 change: 1 addition & 0 deletions src/components/Dialog/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useDialog';
Loading

0 comments on commit 9751e15

Please sign in to comment.