Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}}
/>
);
};
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
2 changes: 1 addition & 1 deletion src/components/Form/styling/Form.scss
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ input[type='number'] {
.str-chat__form-field-error {
margin-left: 0.5rem;
}
}
}
4 changes: 3 additions & 1 deletion src/components/Form/styling/NumericInput.scss
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@
border-radius: var(--button-radius-full, 9999px);
color: var(--text-tertiary, #687385);
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
transition:
border-color 0.15s ease,
color 0.15s ease;

&:hover:not(:disabled) {
color: var(--text-primary, #1a1b25);
Expand Down
25 changes: 18 additions & 7 deletions src/components/Form/styling/SwitchField.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,29 @@
// CSS variables aligned with Figma tokens; fallbacks from get_variable_defs.

.str-chat {
--str-chat__switch-field-background-color: var(--input-cards-bg, var(--background-core-surface-subtle));
--str-chat__switch-field-background-color: var(
--input-cards-bg,
var(--background-core-surface-subtle)
);
--str-chat__switch-field-border-radius: var(--radius-md);
--str-chat__switch-field-title-font-size: var(--typography-font-size-sm, 14px);
--str-chat__switch-field-title-font-weight: var(--typography-font-weight-medium, 500);
--str-chat__switch-field-title-line-height: var(--typography-line-height-tight, 16px);
--str-chat__switch-field-title-color: var(--text-primary, #1a1b25);
--str-chat__switch-field-description-font-size: var(--typography-font-size-xs, 12px);
--str-chat__switch-field-description-font-weight: var(--typography-font-weight-regular, 400);
--str-chat__switch-field-description-font-weight: var(
--typography-font-weight-regular,
400
);
--str-chat__switch-field-description-color: var(--text-tertiary, #687385);
--str-chat__switch-field__track-off-bg: var(--control-toggle-switch-bg, var(--border-core-on-surface, #a3acba));
--str-chat__switch-field__track-on-bg: var(--control-toggle-switch-bg-selected, #005fff);
--str-chat__switch-field__track-off-bg: var(
--control-toggle-switch-bg,
var(--border-core-on-surface, #a3acba)
);
--str-chat__switch-field__track-on-bg: var(
--control-toggle-switch-bg-selected,
#005fff
);
--str-chat__switch-field__track-thumb-bg: var(--base-white, #ffffff);
--str-chat__switch-field__track-height: 24px;
--str-chat__switch-field__track-radius: var(--button-radius-full, 9999px);
Expand All @@ -25,12 +37,11 @@
}

.str-chat__form__switch-field {

display: flex;
align-items: center;
gap: var(--spacing-sm);
width: 100%;
padding: var(--spacing-sm ) var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-md);
background-color: var(--str-chat__switch-field-background-color);
border-radius: var(--str-chat__switch-field-border-radius);
box-sizing: border-box;
Expand Down Expand Up @@ -137,7 +148,7 @@

.str-chat__form__switch-field__label,
.str-chat__form__switch-field__label__content {
flex: 1
flex: 1;
}

.str-chat__form__switch-field__label--as-error {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Form/styling/TextInputFieldset.scss
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,4 @@
}
}
}
}
}
2 changes: 1 addition & 1 deletion src/components/Icons/styling/Icons.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
width: 1em;
height: 1em;
fill: currentColor;
}
}
2 changes: 1 addition & 1 deletion src/components/Icons/styling/index.scss
Original file line number Diff line number Diff line change
@@ -1 +1 @@
@use 'Icons';
@use 'Icons';
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@

.str-chat__audio-recorder__recording-preview {
.str-chat__icon--microphone {
height: var(--icon-size-sm);
width: var(--icon-size-sm);
color: var(--button-destructive-text);
}
Expand Down
9 changes: 4 additions & 5 deletions src/components/Message/MessageSimple.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
isMessageBlocked,
isMessageBounced,
isMessageEdited,
isMessageErrorRetryable,
isOnlyEmojis,
messageHasAttachments,
messageHasGiphyAttachment,
Expand Down Expand Up @@ -52,7 +53,6 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
groupedByUser,
handleAction,
handleOpenThread,
handleRetry,
highlighted,
isMessageAIGenerated,
isMyMessage,
Expand Down Expand Up @@ -120,15 +120,14 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
const showReplyCountButton = !threadList && !!message.reply_count;
const showIsReplyInChannel =
!threadList && message.show_in_channel && message.parent_id;
const allowRetry = message.status === 'failed' && message.error?.status !== 403;
const allowRetry = isMessageErrorRetryable(message);
const isBounced = isMessageBounced(message);
const isEdited = isMessageEdited(message) && !isAIGenerated;

let handleClick: (() => void) | undefined = undefined;

if (allowRetry) {
handleClick = () => handleRetry(message);
} else if (isBounced) {
// todo: should we keep the behavior with click-on-blubble -> show the MessageBounceModal?
if (isBounced) {
handleClick = () => setIsBounceDialogOpen(true);
} else if (isEdited) {
handleClick = () => setEditedTimestampOpen((prev) => !prev);
Expand Down
Loading
Loading