Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
1 change: 0 additions & 1 deletion examples/vite/src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

// v3 CSS import
@import url('stream-chat-react/dist/css/index.css') layer(stream-new);
@import url('stream-chat-react/dist/css/emojis.css') layer(stream-new);
@import url('./AppSettings/AppSettings.scss') layer(stream-app-overrides);

:root {
Expand Down
2 changes: 1 addition & 1 deletion examples/vite/src/stream-imports-layout.scss
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
@use 'stream-chat-react/dist/scss/v2/Location/Location-layout';
//@use 'stream-chat-react/dist/scss/v2/Message/Message-layout';
//@use 'stream-chat-react/dist/scss/v2/MessageActionsBox/MessageActionsBox-layout';
@use 'stream-chat-react/dist/scss/v2/MessageBouncePrompt/MessageBouncePrompt-layout';
//@use 'stream-chat-react/dist/scss/v2/MessageBouncePrompt/MessageBouncePrompt-layout';
//@use 'stream-chat-react/dist/scss/v2/MessageInput/MessageInput-layout'; // X
@use 'stream-chat-react/dist/scss/v2/MessageList/MessageList-layout';
@use 'stream-chat-react/dist/scss/v2/MessageList/VirtualizedMessageList-layout';
Expand Down
2 changes: 1 addition & 1 deletion examples/vite/src/stream-imports-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
@use 'stream-chat-react/dist/scss/v2/Location/Location-theme';
//@use 'stream-chat-react/dist/scss/v2/Message/Message-theme';
//@use 'stream-chat-react/dist/scss/v2/MessageActionsBox/MessageActionsBox-theme';
@use 'stream-chat-react/dist/scss/v2/MessageBouncePrompt/MessageBouncePrompt-theme';
//@use 'stream-chat-react/dist/scss/v2/MessageBouncePrompt/MessageBouncePrompt-theme';
@use 'stream-chat-react/dist/scss/v2/MessageList/MessageList-theme';
@use 'stream-chat-react/dist/scss/v2/MessageList/VirtualizedMessageList-theme';
// @use 'stream-chat-react/dist/scss/v2/MessageReactions/MessageReactions-theme';
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@
"emoji-mart": "^5.4.0",
"react": "^19.0.0 || ^18.0.0 || ^17.0.0",
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0",
"stream-chat": "^9.32.0"
"stream-chat": "^9.35.0"
},
"peerDependenciesMeta": {
"@breezystack/lamejs": {
Expand Down Expand Up @@ -212,7 +212,7 @@
"react-dom": "^19.0.0",
"sass": "^1.97.2",
"semantic-release": "^25.0.2",
"stream-chat": "^9.32.0",
"stream-chat": "^9.35.0",
"ts-jest": "^29.2.5",
"typescript": "^5.4.5",
"typescript-eslint": "^8.17.0",
Expand All @@ -221,7 +221,7 @@
"scripts": {
"clean": "rm -rf dist",
"build": "yarn clean && concurrently './scripts/copy-css.sh' 'yarn build-translations' 'vite build' 'tsc --project tsconfig.lib.json' 'yarn build-styling'",
"build-styling": "sass src/styling/index.scss dist/css/index.css && sass src/plugins/Emojis/styling/index.scss dist/css/emojis.css",
"build-styling": "sass src/styling/index.scss dist/css/index.css",
"build-translations": "i18next-cli extract",
"coverage": "jest --collectCoverage && codecov",
"lint": "yarn prettier --list-different && yarn eslint && yarn validate-translations",
Expand Down
10 changes: 9 additions & 1 deletion src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -967,15 +967,23 @@ const ChannelInner = (
};

const retrySendMessage = async (localMessage: LocalMessage) => {
/**
* If type is not checked, and we for example send message.type === 'error',
* then request fails with error: "message.type must be one of ['' regular system]".
* For now, we re-send any other type to prevent breaking behavior.
*/

const type = localMessage.type === 'error' ? 'regular' : localMessage.type;
updateMessage({
...localMessage,
error: undefined,
status: 'sending',
type,
});

await doSendMessage({
localMessage,
message: localMessageToNewMessagePayload(localMessage),
message: localMessageToNewMessagePayload({ ...localMessage, type }),
});
};

Expand Down
88 changes: 85 additions & 3 deletions src/components/Dialog/base/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import React, {
type ComponentProps,
type ComponentType,
Expand All @@ -10,6 +11,9 @@ import React, {
} from 'react';
import clsx from 'clsx';
import { IconChevronLeft } from '../../Icons';
import { useDialogIsOpen } from '../hooks';
import type { DialogAnchorProps } from '../service/DialogAnchor';
import { DialogAnchor } from '../service/DialogAnchor';

export const ContextMenuBackButton = ({
children,
Expand Down Expand Up @@ -96,7 +100,7 @@ type ContextMenuLevel = {
menuClassName?: string;
};

export type ContextMenuProps = Omit<ComponentProps<'div'>, 'children'> & {
type ContextMenuBaseProps = Omit<ComponentProps<'div'>, 'children'> & {
backLabel?: ReactNode;
items: ContextMenuItemComponent[];
Header?: ContextMenuHeaderComponent;
Expand All @@ -106,7 +110,24 @@ export type ContextMenuProps = Omit<ComponentProps<'div'>, 'children'> & {
onMenuLevelChange?: (level: number) => void;
};

export const ContextMenu = ({
/** When provided, ContextMenu renders inside DialogAnchor and wires menu level for submenu alignment. */
type ContextMenuAnchorProps = Partial<
Pick<
DialogAnchorProps,
| 'id'
| 'dialogManagerId'
| 'placement'
| 'referenceElement'
| 'tabIndex'
| 'trapFocus'
| 'allowFlip'
| 'focus'
>
>;

export type ContextMenuProps = ContextMenuBaseProps & ContextMenuAnchorProps;

function ContextMenuContent({
backLabel = 'Back',
className,
Header,
Expand All @@ -116,7 +137,7 @@ export const ContextMenu = ({
onClose,
onMenuLevelChange,
...props
}: ContextMenuProps) => {
}: ContextMenuBaseProps) {
const rootLevel = useMemo<ContextMenuLevel>(
() => ({
Header,
Expand Down Expand Up @@ -207,4 +228,65 @@ export const ContextMenu = ({
</ContextMenuRoot>
</ContextMenuContext.Provider>
);
}

export const ContextMenu = (props: ContextMenuProps) => {
const {
allowFlip,
dialogManagerId,
focus,
id,
placement,
referenceElement,
tabIndex,
trapFocus,
...menuProps
} = props;

const isAnchored = id != null;

const [menuLevel, setMenuLevel] = useState(1);
const open = useDialogIsOpen(id ?? '', dialogManagerId);

useEffect(() => {
if (isAnchored && !open) setMenuLevel(1);
}, [isAnchored, open]);

const content = (
<ContextMenuContent
{...menuProps}
onMenuLevelChange={isAnchored ? setMenuLevel : menuProps.onMenuLevelChange}
/>
);

if (isAnchored) {
const {
backLabel: _b,
Header: _h,
items: _i,
ItemsWrapper: _w,
menuClassName: _m,
onClose: _c,
onMenuLevelChange: _l,
...anchorDivProps
} = menuProps;
return (
<DialogAnchor
allowFlip={allowFlip}
dialogManagerId={dialogManagerId}
focus={focus}
id={id}
placement={placement}
referenceElement={referenceElement}
tabIndex={tabIndex}
trapFocus={trapFocus}
updateKey={menuLevel}
{...anchorDivProps}
>
{content}
</DialogAnchor>
);
}

return content;
};
24 changes: 21 additions & 3 deletions src/components/Dialog/base/ContextMenuButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,24 @@ const ContextMenuButtonWithSubmenu = ({

type ContextMenuButtonProps = BaseContextMenuButtonProps;

export const ContextMenuButton = (props: ContextMenuButtonProps) => (
<BaseContextMenuButton {...props} />
);
export const ContextMenuButton = ({
onBlur,
onFocus,
...props
}: ContextMenuButtonProps) => {
const [isFocused, setIsFocused] = useState(false);
return (
<BaseContextMenuButton
{...props}
aria-selected={isFocused ? 'true' : 'false'}
onBlur={(e) => {
setIsFocused(false);
onBlur?.(e);
}}
onFocus={(e) => {
setIsFocused(true);
onFocus?.(e);
}}
/>
);
};
59 changes: 59 additions & 0 deletions src/components/Dialog/base/Prompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { type ComponentProps, type ComponentType, forwardRef } from 'react';
import clsx from 'clsx';

export const Root = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function PromptRoot(
{ children, className, ...props }: ComponentProps<'div'>,
ref,
) {
return (
<div {...props} className={clsx('str-chat__prompt-root', className)} ref={ref}>
{children}
</div>
);
});

export type PromptHeaderProps = ComponentProps<'div'> & {
title?: string;
description?: string;
Icon?: ComponentType;
};

export const Header = forwardRef<HTMLDivElement, PromptHeaderProps>(function PromptRoot(
{ children, className, description, Icon, title, ...props },
ref,
) {
return (
<div {...props} className={clsx('str-chat__prompt-header', className)} ref={ref}>
{title ? (
<>
{Icon && <Icon />}
<div className='str-chat__prompt-header__copy'>
<div className='str-chat__prompt-header__title'>{title}</div>
{description && (
<div className='str-chat__prompt-header__description'>{description}</div>
)}
</div>
</>
) : (
children
)}
</div>
);
});

const Actions = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function PromptRoot(
{ children, className, ...props },
ref,
) {
return (
<div {...props} className={clsx('str-chat__prompt-actions', className)} ref={ref}>
{children}
</div>
);
});

export const Prompt = {
Actions,
Header,
Root,
};
1 change: 1 addition & 0 deletions src/components/Dialog/base/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './Callout';
export * from './ContextMenuButton';
export * from './ContextMenu';
export * from './Prompt';
22 changes: 17 additions & 5 deletions src/components/Dialog/service/DialogAnchor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import clsx from 'clsx';
import type { ComponentProps, PropsWithChildren } from 'react';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { FocusScope } from '@react-aria/focus';
import { DialogPortalEntry } from './DialogPortal';
import { useDialog, useDialogIsOpen } from '../hooks';
Expand Down Expand Up @@ -29,22 +29,33 @@ export function useDialogAnchor<T extends HTMLElement>({
placement,
});

// Freeze reference when dialog opens so submenus (e.g. ContextMenu level 2+) stay aligned to the original anchor
const frozenReferenceRef = useRef<HTMLElement | null>(null);
if (open && referenceElement && !frozenReferenceRef.current) {
frozenReferenceRef.current = referenceElement;
}
if (!open) {
frozenReferenceRef.current = null;
}
const effectiveReference = open ? frozenReferenceRef.current : referenceElement;

useEffect(() => {
refs.setReference(referenceElement);
}, [referenceElement, refs]);
refs.setReference(effectiveReference);
}, [effectiveReference, refs]);

useEffect(() => {
refs.setFloating(popperElement);
}, [popperElement, refs]);

useEffect(() => {
if (open && popperElement) {
if (open && popperElement && effectiveReference) {
// Re-run when reference becomes available (e.g. after ref is set) or when updateKey changes (e.g. submenu 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 is non-null only if popperElement is non-null
update?.();
}
}, [open, placement, popperElement, update, updateKey]);
}, [open, placement, popperElement, update, updateKey, effectiveReference]);

if (popperElement && !open) {
setPopperElement(null);
Expand Down Expand Up @@ -83,6 +94,7 @@ export const DialogAnchor = ({
}: DialogAnchorProps) => {
const dialog = useDialog({ dialogManagerId, id });
const open = useDialogIsOpen(id, dialogManagerId);

const { setPopperElement, styles } = useDialogAnchor<HTMLDivElement>({
allowFlip,
open,
Expand Down
48 changes: 48 additions & 0 deletions src/components/Dialog/styling/Prompt.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@

@mixin flex-column {
display: flex;
flex-direction: column;
align-items: center;
}

.str-chat__prompt-root {
@include flex-column;
gap: var(--spacing-2xl);
padding: var(--spacing-xl);
text-align: center;

.str-chat__prompt-header {
@include flex-column;
gap: var(--spacing-md);
width: 100%;

svg {
height: var(--button-visual-height-sm);
width: var(--button-visual-height-sm);
}

.str-chat__prompt-header__copy {
@include flex-column;
gap: var(--spacing-xs);
width: 100%;

.str-chat__prompt-header__title {
font: var(--str-chat__heading-sm-text);
}

.str-chat__prompt-header__description {
font: var(--str-chat__caption-default-tex);
}
}
}

.str-chat__prompt-actions {
@include flex-column;
gap: var(--spacing-xs);
width: 100%;

button {
width: 100%;
}
}
}
Loading
Loading