Skip to content

Commit 9751e15

Browse files
committed
feat: add centralized dialog management
1 parent eb0d6d4 commit 9751e15

13 files changed

+575
-223
lines changed

src/components/Channel/Channel.tsx

+21-18
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ import type { UnreadMessagesNotificationProps } from '../MessageList';
7676
import { hasMoreMessagesProbably, UnreadMessagesSeparator } from '../MessageList';
7777
import { useChannelContainerClasses } from './hooks/useChannelContainerClasses';
7878
import { findInMsgSetByDate, findInMsgSetById, makeAddNotifications } from './utils';
79+
import { DateSeparator } from '../DateSeparator';
80+
import { DialogsManagerProvider } from '../Dialog';
81+
import { EventComponent } from '../EventComponent';
82+
import { defaultReactionOptions, ReactionOptions } from '../Reactions';
7983
import { getChannel } from '../../utils';
8084

8185
import type { MessageProps } from '../Message/types';
@@ -96,9 +100,6 @@ import {
96100
getVideoAttachmentConfiguration,
97101
} from '../Attachment/attachment-sizing';
98102
import type { URLEnrichmentConfig } from '../MessageInput/hooks/useLinkPreviews';
99-
import { defaultReactionOptions, ReactionOptions } from '../Reactions';
100-
import { EventComponent } from '../EventComponent';
101-
import { DateSeparator } from '../DateSeparator';
102103

103104
type ChannelPropsForwardedToComponentContext<
104105
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
@@ -1241,7 +1242,7 @@ const ChannelInner = <
12411242
],
12421243
);
12431244

1244-
const componentContextValue: ComponentContextValue<StreamChatGenerics> = useMemo(
1245+
const componentContextValue = useMemo<ComponentContextValue<StreamChatGenerics>>(
12451246
() => ({
12461247
Attachment: props.Attachment || DefaultAttachment,
12471248
AttachmentPreviewList: props.AttachmentPreviewList,
@@ -1329,20 +1330,22 @@ const ChannelInner = <
13291330

13301331
return (
13311332
<div className={clsx(className, windowsEmojiClass)}>
1332-
<ChannelStateProvider value={channelStateContextValue}>
1333-
<ChannelActionProvider value={channelActionContextValue}>
1334-
<ComponentProvider value={componentContextValue}>
1335-
<TypingProvider value={typingContextValue}>
1336-
<div className={`${chatContainerClass}`}>
1337-
{dragAndDropWindow && (
1338-
<DropzoneProvider {...optionalMessageInputProps}>{children}</DropzoneProvider>
1339-
)}
1340-
{!dragAndDropWindow && <>{children}</>}
1341-
</div>
1342-
</TypingProvider>
1343-
</ComponentProvider>
1344-
</ChannelActionProvider>
1345-
</ChannelStateProvider>
1333+
<DialogsManagerProvider>
1334+
<ChannelStateProvider value={channelStateContextValue}>
1335+
<ChannelActionProvider value={channelActionContextValue}>
1336+
<ComponentProvider value={componentContextValue}>
1337+
<TypingProvider value={typingContextValue}>
1338+
<div className={`${chatContainerClass}`}>
1339+
{dragAndDropWindow && (
1340+
<DropzoneProvider {...optionalMessageInputProps}>{children}</DropzoneProvider>
1341+
)}
1342+
{!dragAndDropWindow && <>{children}</>}
1343+
</div>
1344+
</TypingProvider>
1345+
</ComponentProvider>
1346+
</ChannelActionProvider>
1347+
</ChannelStateProvider>
1348+
</DialogsManagerProvider>
13461349
</div>
13471350
);
13481351
};
+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { Placement } from '@popperjs/core';
2+
import React, { ComponentProps, PropsWithChildren, useEffect, useRef } from 'react';
3+
import { usePopper } from 'react-popper';
4+
import { useDialogIsOpen } from './hooks';
5+
import { DialogPortalEntry } from './DialogPortal';
6+
7+
export interface DialogAnchorOptions {
8+
open: boolean;
9+
placement: Placement;
10+
referenceElement: HTMLElement | null;
11+
}
12+
13+
export function useDialogAnchor<T extends HTMLElement>({
14+
open,
15+
placement,
16+
referenceElement,
17+
}: DialogAnchorOptions) {
18+
const popperElementRef = useRef<T>(null);
19+
const { attributes, styles, update } = usePopper(referenceElement, popperElementRef.current, {
20+
modifiers: [
21+
{
22+
name: 'eventListeners',
23+
options: {
24+
// It's not safe to update popper position on resize and scroll, since popper's
25+
// reference element might not be visible at the time.
26+
resize: false,
27+
scroll: false,
28+
},
29+
},
30+
],
31+
placement,
32+
});
33+
34+
useEffect(() => {
35+
if (open) {
36+
// Since the popper's reference element might not be (and usually is not) visible
37+
// all the time, it's safer to force popper update before showing it.
38+
update?.();
39+
}
40+
}, [open, update]);
41+
42+
return {
43+
attributes,
44+
popperElementRef,
45+
styles,
46+
};
47+
}
48+
49+
type DialogAnchorProps = PropsWithChildren<Partial<DialogAnchorOptions>> & {
50+
id: string;
51+
} & ComponentProps<'div' | 'span'>;
52+
53+
export const DialogAnchor = ({
54+
children,
55+
className,
56+
id,
57+
placement = 'auto',
58+
referenceElement = null,
59+
}: DialogAnchorProps) => {
60+
const open = useDialogIsOpen(id);
61+
const { attributes, popperElementRef, styles } = useDialogAnchor<HTMLDivElement>({
62+
open,
63+
placement,
64+
referenceElement,
65+
});
66+
67+
return (
68+
<DialogPortalEntry dialogId={id}>
69+
<div
70+
{...attributes.popper}
71+
className={className}
72+
ref={popperElementRef}
73+
style={styles.popper}
74+
>
75+
{children}
76+
</div>
77+
</DialogPortalEntry>
78+
);
79+
};
+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React, { PropsWithChildren, useEffect, useLayoutEffect, useState } from 'react';
2+
import { createPortal } from 'react-dom';
3+
import type { DialogsManager } from './DialogsManager';
4+
import { useDialogIsOpen } from './hooks';
5+
import { useDialogsManager } from '../../context';
6+
7+
export const DialogPortalDestination = () => {
8+
const { dialogsManager } = useDialogsManager();
9+
const [shouldRender, setShouldRender] = useState(!!dialogsManager.openDialogCount);
10+
useEffect(
11+
() =>
12+
dialogsManager.on('openCountChange', {
13+
listener: (dm: DialogsManager) => {
14+
setShouldRender(dm.openDialogCount > 0);
15+
},
16+
}),
17+
[dialogsManager],
18+
);
19+
20+
return (
21+
<>
22+
<div
23+
className='str-chat__dialog-overlay'
24+
onClick={() => dialogsManager.closeAll()}
25+
style={{
26+
height: '100%',
27+
inset: '0',
28+
overflow: 'hidden',
29+
position: 'absolute',
30+
width: '100%',
31+
zIndex: shouldRender ? '2' : '-1',
32+
}}
33+
>
34+
<div data-str-chat__portal-id={dialogsManager.id} />
35+
</div>
36+
</>
37+
);
38+
};
39+
40+
type DialogPortalEntryProps = {
41+
dialogId: string;
42+
};
43+
44+
export const DialogPortalEntry = ({
45+
children,
46+
dialogId,
47+
}: PropsWithChildren<DialogPortalEntryProps>) => {
48+
const { dialogsManager } = useDialogsManager();
49+
const dialogIsOpen = useDialogIsOpen(dialogId);
50+
const [portalDestination, setPortalDestination] = useState<Element | null>(null);
51+
useLayoutEffect(() => {
52+
const destination = document.querySelector(
53+
`div[data-str-chat__portal-id="${dialogsManager.id}"]`,
54+
);
55+
if (!destination) return;
56+
setPortalDestination(destination);
57+
}, [dialogsManager, dialogIsOpen]);
58+
59+
if (!portalDestination) return null;
60+
61+
return createPortal(children, portalDestination);
62+
};
+178
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
type DialogId = string;
2+
3+
export type GetOrCreateParams = {
4+
id: DialogId;
5+
isOpen?: boolean;
6+
};
7+
8+
export type Dialog = {
9+
close: () => void;
10+
id: DialogId;
11+
isOpen: boolean | undefined;
12+
open: (zIndex?: number) => void;
13+
remove: () => void;
14+
toggle: () => void;
15+
toggleSingle: () => void;
16+
};
17+
18+
type DialogEvent = { type: 'close' | 'open' | 'openCountChange' };
19+
20+
const dialogsManagerEvents = ['openCountChange'] as const;
21+
type DialogsManagerEvent = { type: typeof dialogsManagerEvents[number] };
22+
23+
type DialogEventHandler = (dialog: Dialog) => void;
24+
type DialogsManagerEventHandler = (dialogsManager: DialogsManager) => void;
25+
26+
type DialogInitOptions = {
27+
id?: string;
28+
};
29+
30+
const noop = (): void => undefined;
31+
32+
export class DialogsManager {
33+
id: string;
34+
openDialogCount = 0;
35+
dialogs: Record<DialogId, Dialog> = {};
36+
private dialogEventListeners: Record<
37+
DialogId,
38+
Partial<Record<DialogEvent['type'], DialogEventHandler[]>>
39+
> = {};
40+
private dialogsManagerEventListeners: Record<
41+
DialogsManagerEvent['type'],
42+
DialogsManagerEventHandler[]
43+
> = { openCountChange: [] };
44+
45+
constructor({ id }: DialogInitOptions = {}) {
46+
this.id = id ?? new Date().getTime().toString();
47+
}
48+
49+
getOrCreate({ id, isOpen = false }: GetOrCreateParams) {
50+
let dialog = this.dialogs[id];
51+
if (!dialog) {
52+
dialog = {
53+
close: () => {
54+
this.close(id);
55+
},
56+
id,
57+
isOpen,
58+
open: () => {
59+
this.open({ id });
60+
},
61+
remove: () => {
62+
this.remove(id);
63+
},
64+
toggle: () => {
65+
this.toggleOpen({ id });
66+
},
67+
toggleSingle: () => {
68+
this.toggleOpenSingle({ id });
69+
},
70+
};
71+
this.dialogs[id] = dialog;
72+
}
73+
return dialog;
74+
}
75+
76+
on(
77+
eventType: DialogEvent['type'] | DialogsManagerEvent['type'],
78+
{ id, listener }: { listener: DialogEventHandler | DialogsManagerEventHandler; id?: DialogId },
79+
) {
80+
if (dialogsManagerEvents.includes(eventType as DialogsManagerEvent['type'])) {
81+
this.dialogsManagerEventListeners[eventType as DialogsManagerEvent['type']].push(
82+
listener as DialogsManagerEventHandler,
83+
);
84+
return () => {
85+
this.off(eventType, { listener });
86+
};
87+
}
88+
if (!id) return noop;
89+
90+
if (!this.dialogEventListeners[id]) {
91+
this.dialogEventListeners[id] = { close: [], open: [] };
92+
}
93+
this.dialogEventListeners[id][eventType] = [
94+
...(this.dialogEventListeners[id][eventType] ?? []),
95+
listener as DialogEventHandler,
96+
];
97+
return () => {
98+
this.off(eventType, { id, listener });
99+
};
100+
}
101+
102+
off(
103+
eventType: DialogEvent['type'] | DialogsManagerEvent['type'],
104+
{ id, listener }: { listener: DialogEventHandler | DialogsManagerEventHandler; id?: DialogId },
105+
) {
106+
if (dialogsManagerEvents.includes(eventType as DialogsManagerEvent['type'])) {
107+
const eventListeners = this.dialogsManagerEventListeners[
108+
eventType as DialogsManagerEvent['type']
109+
];
110+
eventListeners?.filter((l) => l !== listener);
111+
return;
112+
}
113+
114+
if (!id) return;
115+
116+
const eventListeners = this.dialogEventListeners[id]?.[eventType];
117+
if (!eventListeners) return;
118+
this.dialogEventListeners[id][eventType] = eventListeners.filter((l) => l !== listener);
119+
}
120+
121+
open(params: GetOrCreateParams, single?: boolean) {
122+
const dialog = this.getOrCreate(params);
123+
if (dialog.isOpen) return;
124+
if (single) {
125+
this.closeAll();
126+
}
127+
this.dialogs[params.id].isOpen = true;
128+
this.openDialogCount++;
129+
this.dialogsManagerEventListeners.openCountChange.forEach((listener) => listener(this));
130+
this.dialogEventListeners[params.id].open?.forEach((listener) => listener(dialog));
131+
}
132+
133+
close(id: DialogId) {
134+
const dialog = this.dialogs[id];
135+
if (!dialog?.isOpen) return;
136+
dialog.isOpen = false;
137+
this.openDialogCount--;
138+
this.dialogEventListeners[id].close?.forEach((listener) => listener(dialog));
139+
this.dialogsManagerEventListeners.openCountChange.forEach((listener) => listener(this));
140+
}
141+
142+
closeAll() {
143+
Object.values(this.dialogs).forEach((dialog) => dialog.close());
144+
}
145+
146+
toggleOpen(params: GetOrCreateParams) {
147+
if (this.dialogs[params.id].isOpen) {
148+
this.close(params.id);
149+
} else {
150+
this.open(params);
151+
}
152+
}
153+
154+
toggleOpenSingle(params: GetOrCreateParams) {
155+
if (this.dialogs[params.id].isOpen) {
156+
this.close(params.id);
157+
} else {
158+
this.open(params, true);
159+
}
160+
}
161+
162+
remove(id: DialogId) {
163+
const dialogs = { ...this.dialogs };
164+
if (!dialogs[id]) return;
165+
166+
const countListeners =
167+
!!this.dialogEventListeners[id] &&
168+
Object.values(this.dialogEventListeners[id]).reduce((acc, listeners) => {
169+
acc += listeners.length;
170+
return acc;
171+
}, 0);
172+
173+
if (!countListeners) {
174+
delete this.dialogEventListeners[id];
175+
delete dialogs[id];
176+
}
177+
}
178+
}

src/components/Dialog/hooks/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useDialog';

0 commit comments

Comments
 (0)