Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/tutorial/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"emoji-mart": "^5.6.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"stream-chat": "^10.0.0-rc.12",
"stream-chat": "10.0.0-rc.13",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion examples/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"modern-normalize": "^3.0.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"stream-chat": "^10.0.0-rc.12",
"stream-chat": "10.0.0-rc.13",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
Expand Down
38 changes: 37 additions & 1 deletion examples/vite/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ import {
import { ConfigurableMessageActions } from './CustomMessageActions';
import { SidebarToggle } from './Sidebar/SidebarToggle.tsx';
import { CommandModeAttachmentSelector } from './CommandModeAttachmentSelector.tsx';
import { StreamDebugHandles } from './Debug';
import { installUploadHarness } from './SendWhilePendingUploads';
import { streamI18n } from './i18n';
import {
DocumentTitleManager,
Expand Down Expand Up @@ -284,6 +286,8 @@ const formatDocumentTitle = ({
const App = () => {
const { tokenProvider, userId, userImage, userName } = useUser();
const chatView = useAppSettingsSelector((state) => state.chatView);
const { failUploads, sendMessagesWithPendingUploads, slowUploads } =
useAppSettingsSelector((state) => state.composer);
// Project to a stable-shape object rather than returning `state.layout` directly. `layout`
// starts as `{}`, and useStateStore only diffs the keys present in its *cached* selection — so
// a selection that starts empty never notices `channelCid` appearing later, and the modal would
Expand Down Expand Up @@ -505,16 +509,34 @@ const App = () => {
if (!chatClient) return;

chatClient.config.setSetupFunction('messageComposer', ({ composer }) => {
// Settings are read on every upload rather than captured here, so changing them in
// Settings -> Composer takes effect without re-running setup - which matters because a
// custom doUploadRequest cannot be un-set once installed.
if (slowUploads || failUploads !== 'off') {
installUploadHarness(composer, () => {
const {
failUploads: failureMode,
slowUploadMs,
slowUploads: slowArmed,
} = appSettingsStore.getLatestValue().composer;

return { delayMs: slowArmed ? slowUploadMs : 0, failureMode };
});
}

// todo: find a way to register multiple setup functions so that the SDK can have own setup independent from the integrator setup
composer.compositionMiddlewareExecutor.insert({
middleware: [createCommandInjectionMiddleware(composer)],
position: { after: 'stream-io/message-composer-middleware/attachments' },
unique: true,
});

// `unique: true` matters now that this setup function re-runs whenever a Composer setting
// changes - without it each toggle would append another copy of the same middleware.
composer.draftCompositionMiddlewareExecutor.insert({
middleware: [createDraftCommandInjectionMiddleware(composer)],
position: { after: 'stream-io/message-composer-middleware/draft-attachments' },
unique: true,
});

composer.textComposer.middlewareExecutor.insert({
Expand All @@ -540,7 +562,19 @@ const App = () => {
location: { enabled: true },
});
});
}, [chatClient]);
}, [chatClient, failUploads, slowUploads]);

useEffect(() => {
if (!chatClient) return;

// Declarative rather than in the setup function above, which only runs for composers built
// afterwards. A composer picks this up when it is constructed or when it registers
// subscriptions, and the latter is what mounting a channel does - so an open composer sees it
// at once and the rest on their way in.
chatClient.config.setConfig('messageComposer', {
attachments: { pendingUploadsEnabled: sendMessagesWithPendingUploads },
});
}, [chatClient, sendMessagesWithPendingUploads]);

const chatTheme = themeMode === 'dark' ? 'str-chat__theme-dark' : 'messaging light';
const initialAppLayoutStyle = useMemo(
Expand Down Expand Up @@ -636,6 +670,8 @@ const App = () => {
the app is showing. */}
<DocumentTitleManager formatTitle={formatDocumentTitle} />
<ChatSkipNavigation />
{/* Publishes window.streamDebug — see src/Debug/StreamDebugHandles.tsx */}
<StreamDebugHandles />
<div
className='app-chat-layout'
data-variant={messageUiVariant ?? undefined}
Expand Down
36 changes: 27 additions & 9 deletions examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,22 +72,24 @@ const ActionsMenuButton = ({
</div>
);

import { ComposerStateDialog, useComposerStateDialog } from '../../Debug';
import { usePersistentDialog } from './usePersistentDialog';

export const ActionsMenu = ({ iconOnly = true }: { iconOnly?: boolean }) => {
// Shared hook so the dialog is registered with closeOnClickOutside disabled regardless of
// which of the two call sites reaches getOrCreate first.
const { dialog: composerStateDialog } = useComposerStateDialog();
const [menuButtonElement, setMenuButtonElement] = useState<HTMLButtonElement | null>(
null,
);
const { dialog: actionsMenuDialog, dialogManager } = useDialogOnNearestManager({
id: actionsMenuDialogId,
});
const { dialog: notificationDialog } = useDialogOnNearestManager({
id: notificationPromptDialogId,
});
const { dialog: attachmentDialog } = useDialogOnNearestManager({
id: attachmentPromptDialogId,
});
const { dialog: webSocketEventDialog } = useDialogOnNearestManager({
id: webSocketEventPromptDialogId,
});
const { dialog: notificationDialog } = usePersistentDialog(notificationPromptDialogId);
const { dialog: attachmentDialog } = usePersistentDialog(attachmentPromptDialogId);
const { dialog: webSocketEventDialog } = usePersistentDialog(
webSocketEventPromptDialogId,
);
const { dialog: serverSideClientDialog } = useDialogOnNearestManager({
id: serverSideClientPromptDialogId,
});
Expand Down Expand Up @@ -115,13 +117,15 @@ export const ActionsMenu = ({ iconOnly = true }: { iconOnly?: boolean }) => {
<TriggerNotificationAction onTrigger={notificationDialog.open} />
<TriggerAttachmentAction onTrigger={attachmentDialog.open} />
<TriggerWebSocketEventAction onTrigger={webSocketEventDialog.open} />
<TriggerComposerStateInspectorAction onTrigger={composerStateDialog.open} />
{serverSideClientEnabled && (
<TriggerServerSideClientAction onTrigger={serverSideClientDialog.open} />
)}
</ContextMenu>
<NotificationPromptDialog referenceElement={menuButtonElement} />
<AttachmentPromptDialog referenceElement={menuButtonElement} />
<WebSocketEventPromptDialog referenceElement={menuButtonElement} />
<ComposerStateDialog referenceElement={menuButtonElement} />
{serverSideClientEnabled && (
<ServerSideClientPromptDialog referenceElement={menuButtonElement} />
)}
Expand Down Expand Up @@ -184,3 +188,17 @@ function TriggerServerSideClientAction({ onTrigger }: { onTrigger: () => void })
/>
);
}

function TriggerComposerStateInspectorAction({ onTrigger }: { onTrigger: () => void }) {
const { closeMenu } = useContextMenuContext();

return (
<ContextMenuButton
label='Composer State'
onClick={() => {
closeMenu();
onTrigger();
}}
/>
);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useState } from 'react';
import type { LocalAttachment } from 'stream-chat';
import { Prompt, useDialogIsOpen, useDialogOnNearestManager } from 'stream-chat-react';
import { Prompt, useDialogIsOpen } from 'stream-chat-react';
import { useSlotChannels } from 'stream-chat-react/slot-layout';
import { DraggableDialog } from './DraggableDialog';
import { usePersistentDialog } from './usePersistentDialog';

export const attachmentPromptDialogId = 'app-attachment-prompt-dialog';
type AttachmentEditorTab = 'unsupported-file' | 'unsupported-object';
Expand Down Expand Up @@ -51,9 +52,7 @@ export const AttachmentPromptDialog = ({
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Dev tool: act on the first channel currently open in a layout slot.
const channel = useSlotChannels()[0]?.channel;
const { dialog, dialogManager } = useDialogOnNearestManager({
id: attachmentPromptDialogId,
});
const { dialog, dialogManager } = usePersistentDialog(attachmentPromptDialogId);
const dialogIsOpen = useDialogIsOpen(attachmentPromptDialogId, dialogManager?.id);

useEffect(() => {
Expand Down
19 changes: 19 additions & 0 deletions examples/vite/src/AppSettings/ActionsMenu/DraggableDialog.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* `DraggableDialog` applies the drag offset as a `transform` on its inner shell, so the
* `DialogAnchor` element keeps the layout box it was first positioned into — anchored to the
* button that opened it — while the panel is painted somewhere else entirely.
*
* The SDK gives `.str-chat__dialog-contents` `pointer-events: auto`, so that stale invisible
* box swallows clicks meant for the app underneath: you drag a panel aside and the region it
* *used* to occupy stays dead. Only the visible shell may capture.
*
* These rules live in the `stream-app-overrides` layer, which wins over the SDK's rule
* regardless of specificity.
*/
.app__draggable-dialog {
pointer-events: none;
}

.app__draggable-dialog__shell {
pointer-events: auto;
}
39 changes: 35 additions & 4 deletions examples/vite/src/AppSettings/ActionsMenu/DraggableDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,34 +15,63 @@ const clamp = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max);
};

/**
* Stable classes applied alongside whatever the caller passes, so one stylesheet rule can
* govern pointer behaviour for every draggable dialog.
*/
export const DRAGGABLE_DIALOG_ANCHOR_CLASS = 'app__draggable-dialog';
export const DRAGGABLE_DIALOG_SHELL_CLASS = 'app__draggable-dialog__shell';

/**
* A floating, draggable, **non-modal** dialog.
*
* The defaults below deliberately differ from a normal prompt: these panels exist to be kept
* open while you use the app - trigger an event, watch what happens, trigger another - so they
* do not trap focus, do not steal focus on open, and dismiss only via their close button.
* Callers can opt back in per dialog.
*/
export const DraggableDialog = ({
children,
closeOnClickOutside,
closeOnClickOutside = false,
closeOnEscape = false,
dialogClassName,
dialogId,
dialogIsOpen,
dialogManagerId,
dragHandleClassName,
focus = false,
onClose,
promptClassName,
referenceElement,
shellClassName,
title,
trapFocus = false,
}: {
children: ReactNode;
/** Per-dialog override for outside-click dismissal (defaults to the manager's policy). Pass
* `false` for a persistent draggable window that should only close via its own control. */
closeOnClickOutside?: boolean;
/** @default false - dismiss via the close button only. */
closeOnEscape?: boolean;
dialogClassName: string;
dialogId: string;
dialogIsOpen: boolean;
dialogManagerId?: string;
dragHandleClassName: string;
/** Whether the dialog grabs focus when it opens. @default false */
focus?: boolean;
onClose: () => void;
promptClassName: string;
referenceElement: HTMLElement | null;
shellClassName: string;
title: ReactNode;
/**
* Contain focus within the dialog. `true` also makes DialogAnchor render `role="dialog"`
* with `aria-modal`, telling assistive tech the rest of the app is inert - correct for a
* prompt, wrong for a panel meant to stay open while the user works elsewhere.
* @default false
*/
trapFocus?: boolean;
}) => {
const { theme } = useChatContext();
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
Expand Down Expand Up @@ -143,22 +172,24 @@ export const DraggableDialog = ({
return (
<DialogAnchor
allowFlip
className={dialogClassName}
className={clsx(DRAGGABLE_DIALOG_ANCHOR_CLASS, dialogClassName)}
closeOnClickOutside={closeOnClickOutside}
closeOnEscape={closeOnEscape}
dialogManagerId={dialogManagerId}
focus={focus}
id={dialogId}
placement='right-start'
referenceElement={referenceElement}
tabIndex={-1}
trapFocus
trapFocus={trapFocus}
updatePositionOnContentResize
>
{/* `str-chat` and the theme are re-applied here the way `GlobalModal` does: a dialog bound
to the modal manager is portalled to a destination outside any `.str-chat` element, where
the theme's custom properties do not cascade and every `var(--str-chat__…)` resolves
empty. */}
<div
className={clsx('str-chat', theme, shellClassName)}
className={clsx(DRAGGABLE_DIALOG_SHELL_CLASS, 'str-chat', theme, shellClassName)}
ref={shellRef}
style={shellStyle}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ import {
Prompt,
TextInput,
useDialogIsOpen,
useDialogOnNearestManager,
useNotificationApi,
Viewer,
} from 'stream-chat-react';
import { DraggableDialog } from './DraggableDialog';
import { usePersistentDialog } from './usePersistentDialog';
import {
buildNotificationActions,
entryDirectionOptions,
Expand Down Expand Up @@ -506,9 +506,7 @@ export const NotificationPromptDialog = ({
const [globalModalOpen, setGlobalModalOpen] = useState(false);
const chipIdRef = useRef(0);
const { addNotification } = useNotificationApi();
const { dialog, dialogManager } = useDialogOnNearestManager({
id: notificationPromptDialogId,
});
const { dialog, dialogManager } = usePersistentDialog(notificationPromptDialogId);
const dialogIsOpen = useDialogIsOpen(notificationPromptDialogId, dialogManager?.id);

const resetState = useCallback(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useDialog, useNearestDialogManagerContext } from 'stream-chat-react';

/**
* Registers a dialog that must not close when the user clicks elsewhere in the app.
*
* **Every call site touching the same dialog id has to use this hook.**
* `DialogManager.getOrCreate` applies `closeOnClickOutside` only when it *creates* the dialog,
* and these dialogs are resolved twice — once by whatever opens them (the Actions menu, a
* message action) and once by the dialog component itself. Whichever runs first wins, so if one
* of them registered without the override the other's would be silently ignored.
*
* `useDialogOnNearestManager` cannot be used for this: it accepts only `id`.
*/
export const usePersistentDialog = (id: string) => {
const { dialogManager } = useNearestDialogManagerContext() ?? {};
const dialog = useDialog({
closeOnClickOutside: false,
dialogManagerId: dialogManager?.id,
id,
});

return { dialog, dialogManager };
};
4 changes: 4 additions & 0 deletions examples/vite/src/AppSettings/AppSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
IconEmoji,
IconMessageBubble,
IconMessageBubbles,
IconUpload,
} from 'stream-chat-react';
import { ChatViewSelectorButton } from 'stream-chat-react/slot-layout';
import {
Expand All @@ -18,6 +19,7 @@ import {

import { ActionsMenu } from './ActionsMenu';
import { ChannelDetailTab } from './tabs/ChannelDetail';
import { ComposerTab } from './tabs/Composer';
import { ConfigurationTab } from './tabs/Configuration';
import { GeneralTab } from './tabs/General';
import { MessageActionsTab } from './tabs/MessageActions';
Expand All @@ -39,6 +41,7 @@ import { FullscreenProvider } from './fullscreen';

type TabId =
| 'channelDetail'
| 'composer'
| 'configuration'
| 'general'
| 'messageActions'
Expand Down Expand Up @@ -77,6 +80,7 @@ const settingsSectionConfig: SettingsSectionConfig[] = [
id: 'notifications',
title: 'Notifications',
},
{ Content: ComposerTab, Icon: IconUpload, id: 'composer', title: 'Composer' },
{ Content: SidebarTab, Icon: IconSidebar, id: 'sidebar', title: 'Sidebar' },
{ Content: ReactionsTab, Icon: IconEmoji, id: 'reactions', title: 'Reactions' },
{
Expand Down
Loading
Loading