From 8f7fc9d388fd3b77458c79c357171451898a8f10 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Feb 2026 10:57:14 +0100 Subject: [PATCH 1/8] feat: redesign message composer commands, mentions and emoji suggestion lists --- examples/vite/src/App.tsx | 35 ++- examples/vite/src/stream-imports-layout.scss | 2 +- examples/vite/src/stream-imports-theme.scss | 2 +- src/components/Dialog/base/ContextMenu.tsx | 201 ++++++++++++++++- .../Dialog/base/ContextMenuButton.tsx | 82 ++++++- .../Dialog/service/DialogAnchor.tsx | 6 +- .../Dialog/styling/ContextMenu.scss | 99 ++++++++- src/components/Icons/IconFlag.tsx | 8 + src/components/Icons/IconGiphy.tsx | 40 ++++ src/components/Icons/IconLightning.tsx | 16 ++ src/components/Icons/IconMute.tsx | 18 ++ src/components/Icons/IconPaperclip.tsx | 13 ++ src/components/Icons/IconPeopleAdd.tsx | 13 ++ src/components/Icons/IconPeopleRemove.tsx | 13 ++ src/components/Icons/IconVolumeFull.tsx | 13 ++ src/components/Icons/index.ts | 10 +- src/components/Icons/styling/IconFlag.scss | 12 ++ src/components/Icons/styling/IconGiphy.scss | 5 + .../Icons/styling/IconLightning.scss | 9 + src/components/Icons/styling/IconMute.scss | 8 + .../Icons/styling/IconPaperclip.scss | 13 ++ .../Icons/styling/IconPeopleAdd.scss | 12 ++ .../Icons/styling/IconPeopleRemove.scss | 12 ++ .../Icons/styling/IconVolumeFull.scss | 12 ++ src/components/Icons/styling/index.scss | 10 +- src/components/Message/MessageSimple.tsx | 2 +- .../MessageActions/RemindMeSubmenu.tsx | 4 +- .../AttachmentSelector/AttachmentSelector.tsx | 203 +++++++++++++----- .../AttachmentSelector/CommandsSubmenu.tsx | 127 +++++++++++ src/components/MessageInput/CommandChip.tsx | 30 +++ .../MessageInput/MessageComposerActions.tsx | 7 +- .../MessageInput/MessageInputFlat.tsx | 3 + src/components/MessageInput/index.ts | 1 + .../styling/AttachmentPreview.scss | 8 +- .../MessageInput/styling/CommandChip.scss | 27 +++ .../MessageInput/styling/CommandsSubmenu.scss | 5 + .../MessageInput/styling/MessageComposer.scss | 1 - .../MessageInput/styling/index.scss | 2 + .../SuggestionList/CommandItem.tsx | 48 +---- .../SuggestionList/EmoticonItem.tsx | 43 ++-- .../SuggestionList/SuggestionList.tsx | 135 +++++++++--- .../SuggestionList/SuggestionListItem.tsx | 66 +++--- .../SuggestionList/UserItem.tsx | 38 ++-- .../TextareaComposer/TextareaComposer.tsx | 12 +- .../TextareaComposer/hooks/index.ts | 0 .../hooks/useTextareaPlaceholder.ts | 49 +++++ .../styling/SuggestionList.scss | 11 + .../TextareaComposer/styling/index.scss | 1 + src/context/ComponentContext.tsx | 2 + src/i18n/de.json | 3 + src/i18n/en.json | 3 + src/i18n/es.json | 3 + src/i18n/fr.json | 3 + src/i18n/hi.json | 3 + src/i18n/it.json | 3 + src/i18n/ja.json | 3 + src/i18n/ko.json | 3 + src/i18n/nl.json | 3 + src/i18n/pt.json | 3 + src/i18n/ru.json | 3 + src/i18n/tr.json | 3 + src/styling/index.scss | 3 +- src/utils/index.ts | 1 + 63 files changed, 1292 insertions(+), 237 deletions(-) create mode 100644 src/components/Icons/IconFlag.tsx create mode 100644 src/components/Icons/IconGiphy.tsx create mode 100644 src/components/Icons/IconLightning.tsx create mode 100644 src/components/Icons/IconMute.tsx create mode 100644 src/components/Icons/IconPaperclip.tsx create mode 100644 src/components/Icons/IconPeopleAdd.tsx create mode 100644 src/components/Icons/IconPeopleRemove.tsx create mode 100644 src/components/Icons/IconVolumeFull.tsx create mode 100644 src/components/Icons/styling/IconFlag.scss create mode 100644 src/components/Icons/styling/IconGiphy.scss create mode 100644 src/components/Icons/styling/IconLightning.scss create mode 100644 src/components/Icons/styling/IconMute.scss create mode 100644 src/components/Icons/styling/IconPaperclip.scss create mode 100644 src/components/Icons/styling/IconPeopleAdd.scss create mode 100644 src/components/Icons/styling/IconPeopleRemove.scss create mode 100644 src/components/Icons/styling/IconVolumeFull.scss create mode 100644 src/components/MessageInput/AttachmentSelector/CommandsSubmenu.tsx create mode 100644 src/components/MessageInput/CommandChip.tsx create mode 100644 src/components/MessageInput/styling/CommandChip.scss create mode 100644 src/components/MessageInput/styling/CommandsSubmenu.scss create mode 100644 src/components/TextareaComposer/hooks/index.ts create mode 100644 src/components/TextareaComposer/hooks/useTextareaPlaceholder.ts create mode 100644 src/components/TextareaComposer/styling/SuggestionList.scss create mode 100644 src/components/TextareaComposer/styling/index.scss diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 83b317a45f..128c58d9f4 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -1,10 +1,15 @@ -import { useCallback, useEffect, useMemo } from 'react'; +import { type PropsWithChildren, useCallback, useEffect, useMemo } from 'react'; import { ChannelFilters, ChannelOptions, ChannelSort, LocalMessage, TextComposerMiddleware, + Event, + createCommandInjectionMiddleware, + createDraftCommandInjectionMiddleware, + createActiveCommandGuardMiddleware, + createCommandStringExtractionMiddleware, } from 'stream-chat'; import { AIStateIndicator, @@ -18,8 +23,8 @@ import { Thread, ThreadList, useCreateChatClient, - VirtualizedMessageList as MessageList, - // MessageList, + // VirtualizedMessageList as MessageList, + MessageList, Window, WithComponents, } from 'stream-chat-react'; @@ -46,8 +51,8 @@ const isMessageAIGenerated = (message: LocalMessage) => !!message?.ai_generated; const useUser = () => { const userId = useMemo(() => { return ( - import.meta.env.VITE_USER_ID || new URLSearchParams(window.location.search).get('user_id') || + import.meta.env.VITE_USER_ID || localStorage.getItem('user_id') || humanId({ separator: '_', capitalize: false }) ); @@ -96,6 +101,28 @@ const App = () => { if (!chatClient) return; chatClient.setMessageComposerSetupFunction(({ composer }) => { + // 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, + }); + + composer.draftCompositionMiddlewareExecutor.insert({ + middleware: [createDraftCommandInjectionMiddleware(composer)], + position: { after: 'stream-io/message-composer-middleware/draft-attachments' }, + }); + + composer.textComposer.middlewareExecutor.insert({ + middleware: [createActiveCommandGuardMiddleware() as TextComposerMiddleware], + position: { before: 'stream-io/text-composer/commands-middleware' }, + }); + + composer.textComposer.middlewareExecutor.insert({ + middleware: [createCommandStringExtractionMiddleware() as TextComposerMiddleware], + position: { after: 'stream-io/text-composer/commands-middleware' }, + }); + composer.textComposer.middlewareExecutor.insert({ middleware: [ createTextComposerEmojiMiddleware(SearchIndex) as TextComposerMiddleware, diff --git a/examples/vite/src/stream-imports-layout.scss b/examples/vite/src/stream-imports-layout.scss index f888aec92e..9b13ffc4f1 100644 --- a/examples/vite/src/stream-imports-layout.scss +++ b/examples/vite/src/stream-imports-layout.scss @@ -5,7 +5,7 @@ //@use 'stream-chat-react/dist/scss/v2/Avatar/Avatar-layout'; //@use 'stream-chat-react/dist/scss/v2/AttachmentList/AttachmentList-layout'; //@use 'stream-chat-react/dist/scss/v2/AttachmentPreviewList/AttachmentPreviewList-layout'; // X -@use 'stream-chat-react/dist/scss/v2/Autocomplete/Autocomplete-layout'; +//@use 'stream-chat-react/dist/scss/v2/Autocomplete/Autocomplete-layout'; //@use 'stream-chat-react/dist/scss/v2/AudioRecorder/AudioRecorder-layout'; @use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-layout'; @use 'stream-chat-react/dist/scss/v2/Channel/Channel-layout'; diff --git a/examples/vite/src/stream-imports-theme.scss b/examples/vite/src/stream-imports-theme.scss index 997a029ba2..a41570fe1f 100644 --- a/examples/vite/src/stream-imports-theme.scss +++ b/examples/vite/src/stream-imports-theme.scss @@ -8,7 +8,7 @@ //@use 'stream-chat-react/dist/scss/v2/Avatar/Avatar-theme'; //@use 'stream-chat-react/dist/scss/v2/AttachmentList/AttachmentList-theme'; //@use 'stream-chat-react/dist/scss/v2/AudioRecorder/AudioRecorder-theme'; -@use 'stream-chat-react/dist/scss/v2/Autocomplete/Autocomplete-theme'; +//@use 'stream-chat-react/dist/scss/v2/Autocomplete/Autocomplete-theme'; @use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-theme'; @use 'stream-chat-react/dist/scss/v2/Channel/Channel-theme.scss'; @use 'stream-chat-react/dist/scss/v2/ChannelHeader/ChannelHeader-theme'; diff --git a/src/components/Dialog/base/ContextMenu.tsx b/src/components/Dialog/base/ContextMenu.tsx index 9076260106..5cd679d6d7 100644 --- a/src/components/Dialog/base/ContextMenu.tsx +++ b/src/components/Dialog/base/ContextMenu.tsx @@ -1,6 +1,201 @@ -import React, { type ComponentProps } from 'react'; +import React, { + type ComponentProps, + type ComponentType, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; import clsx from 'clsx'; +import { IconChevronRight } from '../../Icons'; -export const ContextMenu = ({ className, ...props }: ComponentProps<'div'>) => ( -
+export const ContextMenuBackButton = ({ + children, + className, + ...props +}: ComponentProps<'button'>) => ( + ); + +export const ContextMenuHeader = ({ + children, + className, + ...props +}: ComponentProps<'div'>) => ( +
+ {children} +
+); + +export const ContextMenuBody = ({ + children, + className, + ...props +}: ComponentProps<'div'>) => ( +
+ {children} +
+); + +export const ContextMenuRoot = React.forwardRef>( + function ContextMenuRoot({ className, ...props }, ref) { + return ( +
+ ); + }, +); + +export type ContextMenuHeaderComponent = ComponentType; +export type ContextMenuSubmenu = ContextMenuItemComponent[]; + +export type ContextMenuOpenSubmenuParams = { + Submenu: ContextMenuSubmenu; + Header?: ContextMenuHeaderComponent; + ItemsWrapper?: ComponentType>; +}; + +export type ContextMenuItemProps = ComponentProps<'button'> & { + closeMenu: () => void; + openSubmenu: (params: ContextMenuOpenSubmenuParams) => void; +}; + +export type ContextMenuItemComponent = ComponentType; + +type ContextMenuContextValue = { + closeMenu: () => void; + openSubmenu: (params: ContextMenuOpenSubmenuParams) => void; + returnToParentMenu: () => void; +}; + +const ContextMenuContext = React.createContext(null); + +export const useContextMenuContext = () => { + const context = useContext(ContextMenuContext); + if (!context) { + throw new Error( + 'Context consumer hook useContextMenuContext must be used within ContextMenuContext', + ); + } + return context; +}; + +type ContextMenuLevel = { + items?: ContextMenuItemComponent[]; + Header?: ContextMenuHeaderComponent; + ItemsWrapper?: ComponentType>; +}; + +export type ContextMenuProps = Omit, 'children'> & { + backLabel?: ReactNode; + items: ContextMenuItemComponent[]; + Header?: ContextMenuHeaderComponent; + ItemsWrapper?: ComponentType>; + onClose?: () => void; + onMenuLevelChange?: (level: number) => void; +}; + +export const ContextMenu = ({ + backLabel = 'Back', + className, + Header, + items, + ItemsWrapper, + onClose, + onMenuLevelChange, + ...props +}: ContextMenuProps) => { + const rootLevel = useMemo( + () => ({ + Header, + items, + ItemsWrapper, + }), + [Header, items, ItemsWrapper], + ); + const [menuStack, setMenuStack] = useState(() => [rootLevel]); + const activeMenu = menuStack[menuStack.length - 1]; + + const closeMenu = useCallback(() => { + onClose?.(); + }, [onClose]); + + const openSubmenu = useCallback( + ({ + Header, + ItemsWrapper: SubmenuItemsWrapper, + Submenu, + }: ContextMenuOpenSubmenuParams) => { + const nextLevel: ContextMenuLevel = { + Header, + items: Submenu, + ItemsWrapper: SubmenuItemsWrapper ?? ItemsWrapper, + }; + setMenuStack((current) => [...current, nextLevel]); + }, + [ItemsWrapper], + ); + + const returnToParentMenu = useCallback(() => { + setMenuStack((current) => + current.length > 1 ? current.slice(0, current.length - 1) : current, + ); + }, []); + + useEffect(() => { + setMenuStack((current) => { + if (current.length === 1 && current[0] === rootLevel) return current; + return [rootLevel]; + }); + }, [rootLevel]); + + useEffect(() => { + onMenuLevelChange?.(menuStack.length); + }, [menuStack.length, onMenuLevelChange]); + + return ( + + + {menuStack.length > 1 && + (activeMenu.Header ? ( + + ) : ( + + + + {backLabel} + + + ))} + + {activeMenu.ItemsWrapper ? ( + + {activeMenu.items?.map((Item, index) => ( + + ))} + + ) : ( + activeMenu.items?.map((Item, index) => ( + + )) + )} + + + + ); +}; diff --git a/src/components/Dialog/base/ContextMenuButton.tsx b/src/components/Dialog/base/ContextMenuButton.tsx index 93d912cf19..3f8a287390 100644 --- a/src/components/Dialog/base/ContextMenuButton.tsx +++ b/src/components/Dialog/base/ContextMenuButton.tsx @@ -1,13 +1,16 @@ import clsx from 'clsx'; -import type { ComponentProps, ComponentType } from 'react'; +import type { ComponentProps, ComponentType, ReactNode } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { PopperLikePlacement } from '../hooks'; import { useDialogIsOpen, useDialogOnNearestManager } from '../hooks'; import { useDialogAnchor } from '../service'; import { IconChevronRight } from '../../Icons'; +import { Avatar, type AvatarProps } from '../../Avatar'; export type BaseContextMenuButtonProps = { + details?: ReactNode; hasSubMenu?: boolean; + label?: ReactNode; Icon?: ComponentType>; SubmenuIcon?: ComponentType>; } & ComponentProps<'button'>; @@ -15,8 +18,10 @@ export type BaseContextMenuButtonProps = { export const BaseContextMenuButton = ({ children, className, + details, hasSubMenu, Icon, + label, SubmenuIcon = IconChevronRight, ...props }: BaseContextMenuButtonProps) => ( @@ -24,25 +29,84 @@ export const BaseContextMenuButton = ({ {...props} className={clsx( 'str-chat__context-menu__button', - { 'str-chat__context-menu__button--with-submenu': !!SubmenuIcon }, + { 'str-chat__context-menu__button--with-submenu': hasSubMenu }, className, )} type='button' > {Icon && } -
{children}
+ {label ? ( + <> +
{label}
+
{details}
+ + ) : ( +
{children}
+ )} {!!hasSubMenu && ( )} ); +export type UserContextMenuButtonProps = Pick & + ComponentProps<'button'>; + +export const UserContextMenuButton = ({ + children, + className, + imageUrl, + userName, + ...props +}: UserContextMenuButtonProps) => ( + +); + +export type EmojiContextMenuButtonProps = { emoji: string } & Pick< + BaseContextMenuButtonProps, + 'label' +> & + ComponentProps<'button'>; + +export const EmojiContextMenuButton = ({ + children, + className, + emoji, + label, + ...props +}: EmojiContextMenuButtonProps) => ( + +); + type ButtonWithSubmenuProps = { Submenu: ComponentType; submenuContainerProps?: ComponentProps<'div'>; submenuPlacement?: PopperLikePlacement; }; +// eslint-disable-next-line @typescript-eslint/no-unused-vars const ContextMenuButtonWithSubmenu = ({ children, className, @@ -168,12 +232,8 @@ const ContextMenuButtonWithSubmenu = ({ ); }; -type ContextMenuButtonProps = BaseContextMenuButtonProps & - Partial; +type ContextMenuButtonProps = BaseContextMenuButtonProps; -export const ContextMenuButton = (props: ContextMenuButtonProps) => - props.Submenu ? ( - - ) : ( - - ); +export const ContextMenuButton = (props: ContextMenuButtonProps) => ( + +); diff --git a/src/components/Dialog/service/DialogAnchor.tsx b/src/components/Dialog/service/DialogAnchor.tsx index 0f7268b968..a7213c5cb3 100644 --- a/src/components/Dialog/service/DialogAnchor.tsx +++ b/src/components/Dialog/service/DialogAnchor.tsx @@ -12,6 +12,7 @@ export interface DialogAnchorOptions { placement: PopperLikePlacement; referenceElement: HTMLElement | null; allowFlip?: boolean; + updateKey?: unknown; } export function useDialogAnchor({ @@ -19,6 +20,7 @@ export function useDialogAnchor({ open, placement, referenceElement, + updateKey, }: DialogAnchorOptions) { const [popperElement, setPopperElement] = useState(null); const { refs, strategy, update, x, y } = usePopoverPosition({ @@ -42,7 +44,7 @@ export function useDialogAnchor({ // update is non-null only if popperElement is non-null update?.(); } - }, [open, placement, popperElement, update]); + }, [open, placement, popperElement, update, updateKey]); if (popperElement && !open) { setPopperElement(null); @@ -76,6 +78,7 @@ export const DialogAnchor = ({ referenceElement = null, tabIndex, trapFocus, + updateKey, ...restDivProps }: DialogAnchorProps) => { const dialog = useDialog({ dialogManagerId, id }); @@ -85,6 +88,7 @@ export const DialogAnchor = ({ open, placement, referenceElement, + updateKey, }); useEffect(() => { diff --git a/src/components/Dialog/styling/ContextMenu.scss b/src/components/Dialog/styling/ContextMenu.scss index 48ddf69ef7..e0abd0bfb0 100644 --- a/src/components/Dialog/styling/ContextMenu.scss +++ b/src/components/Dialog/styling/ContextMenu.scss @@ -20,6 +20,13 @@ --str-chat__dialog-menu-button-color: var(--text-primary); --str-chat__dialog-menu-button-background-color: transparent; --str-chat__dialog-menu-button-hover-background-color: var(--background-core-hover); + --str-chat__dialog-menu-button-active-background-color: var(--background-core-pressed); + --str-chat__dialog-menu-button-focused-background-color: transparent; + --str-chat__dialog-menu-button-disabled-background-color: transparent; + --str-chat__dialog-menu-back-button-hover-background-color: var(--background-core-hover); + --str-chat__dialog-menu-back-button-active-background-color: var(--background-core-pressed); + --str-chat__dialog-menu-back-button-focused-background-color: transparent; + --str-chat__dialog-menu-back-button-disabled-background-color: transparent; --str-chat__dialog-menu-button-border-block-start: none; --str-chat__dialog-menu-button-border-block-end: none; --str-chat__dialog-menu-button-border-inline-start: none; @@ -30,35 +37,117 @@ @include utils.component-layer-overrides('dialog-menu'); display: flex; flex-direction: column; - gap: var(--spacing-xxxs); padding: var(--spacing-xxs); + .str-chat__context-menu__header { + width: 100%; + } + + .str-chat__context-menu__back-button { + @include utils.button-reset; + display: flex; + align-items: center; + gap: var(--spacing-xs); + height: 32px; + width: 100%; + padding: var(--spacing-xs); + text-align: center; + cursor: pointer; + border-radius: var(--radius-md); + + color: var(--text-tertiary); + + font-size: var(--typography-font-size-sm); + font-weight: var(--typography-font-weight-semi-bold, 600); + line-height: var(--typography-line-height-tight); + + &:hover:not(:disabled) { + background-color: var(--str-chat__dialog-menu-back-button-hover-background-color); + } + + &:active:not(:disabled) { + background-color: var(--str-chat__dialog-menu-back-button-active-background-color); + } + + &:focus-visible:not(:disabled) { + background-color: var(--str-chat__dialog-menu-back-button-focused-background-color); + } + + &:disabled { + background-color: var(--str-chat__dialog-menu-back-button-disabled-background-color); + } + + .str-chat__icon--chevron-right { + transform: rotate(180deg); + } + } + + .str-chat__context-menu__body { + width: 100%; + display: flex; + flex-direction: column; + gap: var(--spacing-xxxs); + } + .str-chat__context-menu__button { @include utils.component-layer-overrides('dialog-menu-button'); @include utils.button-reset; display: flex; align-items: center; gap: var(--spacing-xs); + width: 100%; padding: var(--spacing-xs); cursor: pointer; - font-size: var(--typography-font-size-xs); + + font-size: var(--typography-font-size-sm); font-weight: var(--typography-font-weight-semi-bold); line-height: var(--typography-line-height-tight); - &:hover { + &:hover:not(:disabled) { background-color: var(--str-chat__dialog-menu-button-hover-background-color); } + &:active:not(:disabled) { + background-color: var(--str-chat__dialog-menu-button-active-background-color); + } + + &:focus-visible:not(:disabled) { + background-color: var(--str-chat__dialog-menu-button-focused-background-color); + + } + + &:disabled { + background-color: var(--str-chat__dialog-menu-button-disabled-background-color); + } + svg { height: var(--icon-size-sm); width: var(--icon-size-sm); color: var(--text-secondary); } - .str-chat__context-menu__button__text { - flex: 1; + .str-chat__context-menu__button__label { + @include utils.ellipsis-text; + max-width: 80%; + flex: auto; text-align: left; + color: var(--text-primary); + white-space: nowrap; + } + + .str-chat__context-menu__button__details { + flex: 1; + text-align: right; + color: var(--text-tertiary); + font-weight: var(--typography-font-weight-regular); + white-space: nowrap; + } + } + + .str-chat__emoji-context-menu__button, + .str-chat__user-context-menu__button { + font-weight: var(--typography-font-weight-regular, 400); } } } \ No newline at end of file diff --git a/src/components/Icons/IconFlag.tsx b/src/components/Icons/IconFlag.tsx new file mode 100644 index 0000000000..508c9d037f --- /dev/null +++ b/src/components/Icons/IconFlag.tsx @@ -0,0 +1,8 @@ +import clsx from 'clsx'; +import type { ComponentProps } from 'react'; + +export const IconFlag = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconGiphy.tsx b/src/components/Icons/IconGiphy.tsx new file mode 100644 index 0000000000..2292d3a3e8 --- /dev/null +++ b/src/components/Icons/IconGiphy.tsx @@ -0,0 +1,40 @@ +import type { ComponentProps } from 'react'; +import { BaseIcon } from './BaseIcon'; +import clsx from 'clsx'; + +export const IconGiphy = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + + + + + + + + + + + + + + + +); diff --git a/src/components/Icons/IconLightning.tsx b/src/components/Icons/IconLightning.tsx new file mode 100644 index 0000000000..1ad12159af --- /dev/null +++ b/src/components/Icons/IconLightning.tsx @@ -0,0 +1,16 @@ +import { BaseIcon } from './BaseIcon'; +import type { ComponentProps } from 'react'; +import clsx from 'clsx'; + +export const IconLightning = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconMute.tsx b/src/components/Icons/IconMute.tsx new file mode 100644 index 0000000000..caf3d5e219 --- /dev/null +++ b/src/components/Icons/IconMute.tsx @@ -0,0 +1,18 @@ +import type { ComponentProps } from 'react'; +import { BaseIcon } from './BaseIcon'; +import clsx from 'clsx'; + +export const IconMute = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + + +); diff --git a/src/components/Icons/IconPaperclip.tsx b/src/components/Icons/IconPaperclip.tsx new file mode 100644 index 0000000000..73f5874345 --- /dev/null +++ b/src/components/Icons/IconPaperclip.tsx @@ -0,0 +1,13 @@ +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; +import type { ComponentProps } from 'react'; + +export const IconPaperclip = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconPeopleAdd.tsx b/src/components/Icons/IconPeopleAdd.tsx new file mode 100644 index 0000000000..1f9bf09f75 --- /dev/null +++ b/src/components/Icons/IconPeopleAdd.tsx @@ -0,0 +1,13 @@ +import type { ComponentProps } from 'react'; +import { BaseIcon } from './BaseIcon'; +import clsx from 'clsx'; + +export const IconPeopleAdd = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconPeopleRemove.tsx b/src/components/Icons/IconPeopleRemove.tsx new file mode 100644 index 0000000000..74c9c4cf46 --- /dev/null +++ b/src/components/Icons/IconPeopleRemove.tsx @@ -0,0 +1,13 @@ +import clsx from 'clsx'; +import { BaseIcon } from './BaseIcon'; +import type { ComponentProps } from 'react'; + +export const IconPeopleRemove = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/IconVolumeFull.tsx b/src/components/Icons/IconVolumeFull.tsx new file mode 100644 index 0000000000..c195fc5be1 --- /dev/null +++ b/src/components/Icons/IconVolumeFull.tsx @@ -0,0 +1,13 @@ +import type { ComponentProps } from 'react'; +import { BaseIcon } from './BaseIcon'; +import clsx from 'clsx'; + +export const IconVolumeFull = ({ className, ...props }: ComponentProps<'svg'>) => ( + + + +); diff --git a/src/components/Icons/index.ts b/src/components/Icons/index.ts index 0b9a77a8b0..2e616e8585 100644 --- a/src/components/Icons/index.ts +++ b/src/components/Icons/index.ts @@ -10,13 +10,21 @@ export * from './IconCross'; export * from './IconExclamationCircle'; export * from './IconExclamationTriangle'; export * from './IconFile'; +export * from './IconFlag'; +export * from './IconGiphy'; +export * from './IconLightning'; export * from './IconLocationPin'; export * from './IconMicrophone'; +export * from './IconMute'; +export * from './IconPaperclip'; export * from './IconPaperPlane'; export * from './IconPause'; +export * from './IconPeople'; +export * from './IconPeopleAdd'; +export * from './IconPeopleRemove'; export * from './IconPlaySolid'; export * from './IconPlus'; export * from './IconPoll'; export * from './IconVideoCamera'; export * from './IconVideoCameraOutline'; -export * from './IconPeople'; +export * from './IconVolumeFull'; diff --git a/src/components/Icons/styling/IconFlag.scss b/src/components/Icons/styling/IconFlag.scss new file mode 100644 index 0000000000..3054305c81 --- /dev/null +++ b/src/components/Icons/styling/IconFlag.scss @@ -0,0 +1,12 @@ +.str-chat__icon--flag { + fill: none; + width: 16px; + height: 16px; + + path { + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.2; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconGiphy.scss b/src/components/Icons/styling/IconGiphy.scss new file mode 100644 index 0000000000..1f2217067a --- /dev/null +++ b/src/components/Icons/styling/IconGiphy.scss @@ -0,0 +1,5 @@ +.str-chat__icon--giphy { + fill: none; + width: 16px; + height: 16px; +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconLightning.scss b/src/components/Icons/styling/IconLightning.scss new file mode 100644 index 0000000000..eb14a10ad1 --- /dev/null +++ b/src/components/Icons/styling/IconLightning.scss @@ -0,0 +1,9 @@ +.str-chat__icon--lightning { + fill: none; + width: 9px; + height: 12px; + + path { + fill: currentColor; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconMute.scss b/src/components/Icons/styling/IconMute.scss new file mode 100644 index 0000000000..ad679ca0f6 --- /dev/null +++ b/src/components/Icons/styling/IconMute.scss @@ -0,0 +1,8 @@ +.str-chat__icon--mute { + fill: currentColor; + width: 16px; + height: 16px; + + path { + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconPaperclip.scss b/src/components/Icons/styling/IconPaperclip.scss new file mode 100644 index 0000000000..623ff993f4 --- /dev/null +++ b/src/components/Icons/styling/IconPaperclip.scss @@ -0,0 +1,13 @@ +.str-chat { + .str-chat__icon--paperclip { + fill: none; + width: 16px; + height: 16px; + + path { + stroke: currentColor; + stroke-linecap: round; + stroke-width: 1.2; + } + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconPeopleAdd.scss b/src/components/Icons/styling/IconPeopleAdd.scss new file mode 100644 index 0000000000..cceeaef7d3 --- /dev/null +++ b/src/components/Icons/styling/IconPeopleAdd.scss @@ -0,0 +1,12 @@ +.str-chat__icon--people-add { + fill: none; + width: 16px; + height: 16px; + + path { + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.2; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconPeopleRemove.scss b/src/components/Icons/styling/IconPeopleRemove.scss new file mode 100644 index 0000000000..199739fa54 --- /dev/null +++ b/src/components/Icons/styling/IconPeopleRemove.scss @@ -0,0 +1,12 @@ +.str-chat__icon--people-remove { + fill: none; + width: 16px; + height: 16px; + + path { + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.2; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/IconVolumeFull.scss b/src/components/Icons/styling/IconVolumeFull.scss new file mode 100644 index 0000000000..d5b3935a9d --- /dev/null +++ b/src/components/Icons/styling/IconVolumeFull.scss @@ -0,0 +1,12 @@ +.str-chat__icon--volume-full { + fill: none; + width: 16px; + height: 16px; + + path { + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.2; + } +} \ No newline at end of file diff --git a/src/components/Icons/styling/index.scss b/src/components/Icons/styling/index.scss index 05f3023c2a..da02ddb9e4 100644 --- a/src/components/Icons/styling/index.scss +++ b/src/components/Icons/styling/index.scss @@ -10,12 +10,20 @@ @use 'IconExclamationCircle'; @use 'IconExclamationTriangle'; @use 'IconFile'; +@use 'IconFlag'; +@use 'IconGiphy'; +@use 'IconLightning'; @use 'IconLocationPin'; @use 'IconMicrophone'; +@use 'IconMute'; +@use 'IconPaperclip'; @use 'IconPaperPlane'; @use 'IconPause'; +@use 'IconPeople'; +@use 'IconPeopleAdd'; +@use 'IconPeopleRemove'; @use 'IconPlaySolid'; @use 'IconPlus'; @use 'IconPoll'; @use 'IconVideoCameraOutline'; -@use 'IconPeople'; \ No newline at end of file +@use 'IconVolumeFull'; diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx index 0c7485afe1..af1fa43531 100644 --- a/src/components/Message/MessageSimple.tsx +++ b/src/components/Message/MessageSimple.tsx @@ -194,7 +194,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
{poll && } - {finalAttachments?.length && !message.quoted_message ? ( + {finalAttachments?.length ? ( ) : null} {isAIGenerated ? ( diff --git a/src/components/MessageActions/RemindMeSubmenu.tsx b/src/components/MessageActions/RemindMeSubmenu.tsx index f205c1be7d..3058aaabfd 100644 --- a/src/components/MessageActions/RemindMeSubmenu.tsx +++ b/src/components/MessageActions/RemindMeSubmenu.tsx @@ -13,8 +13,8 @@ export const RemindMeActionButton = ({ {t('Remind Me')} diff --git a/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx b/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx index a4a999b732..86f18d1895 100644 --- a/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx +++ b/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx @@ -1,9 +1,23 @@ -import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; +import React, { + type ComponentType, + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { CommandResponse } from 'stream-chat'; import { useAttachmentManagerState, useMessageComposer } from '../hooks'; import { CHANNEL_CONTAINER_ID } from '../../Channel/constants'; import { ContextMenu, ContextMenuButton, + type ContextMenuHeaderComponent, + type ContextMenuItemComponent, + type ContextMenuItemProps, + type ContextMenuOpenSubmenuParams, + type ContextMenuSubmenu, DialogAnchor, useDialogIsOpen, useDialogOnNearestManager, @@ -25,8 +39,15 @@ import { import { useStableId } from '../../UtilityComponents/useStableId'; import clsx from 'clsx'; import { Button, type ButtonProps } from '../../Button'; -import { IconCommand, IconFile, IconLocationPin, IconPlus, IconPoll } from '../../Icons'; +import { + IconCommand, + IconLocationPin, + IconPaperclip, + IconPlus, + IconPoll, +} from '../../Icons'; import { useIsCooldownActive } from '../hooks/useIsCooldownActive'; +import { CommandContextMenuItem, CommandsSubmenuHeader } from './CommandsSubmenu'; const AttachmentSelectorMenuInitButtonIcon = () => { const { AttachmentSelectorInitiationButtonContents } = useComponentContext(); @@ -98,26 +119,41 @@ export type AttachmentSelectorModalContentProps = { close: () => void; }; +export type AttachmentSelectorAction = { + ActionButton: ComponentType; + id?: string; + ModalContent?: React.ComponentType; + Submenu?: ContextMenuSubmenu; + Header?: ContextMenuHeaderComponent; + type: 'uploadFile' | 'createPoll' | 'addLocation' | 'selectCommand' | (string & {}); +}; + export type AttachmentSelectorActionProps = { closeMenu: () => void; openModalForAction: (actionType: AttachmentSelectorAction['type']) => void; -}; - -export type AttachmentSelectorAction = { - ActionButton: React.ComponentType; - type: 'uploadFile' | 'createPoll' | 'addLocation' | 'selectCommand' | (string & {}); - ModalContent?: React.ComponentType; + openSubmenu: (params: ContextMenuOpenSubmenuParams) => void; + submenuItems?: ContextMenuSubmenu; + submenuHeader?: ContextMenuHeaderComponent; }; export const DefaultAttachmentSelectorComponents = { - // todo: we do not know how the submenu should look like - Command() { + Command({ openSubmenu, submenuHeader, submenuItems }: AttachmentSelectorActionProps) { const { t } = useTranslationContext(); return ( 0) || + (!Array.isArray(submenuItems) && !!submenuItems) + } Icon={IconCommand} - Submenu={() => null} + onClick={() => { + const hasSubmenu = + (Array.isArray(submenuItems) && submenuItems.length > 0) || + (!Array.isArray(submenuItems) && !!submenuItems); + if (!hasSubmenu) return; + openSubmenu({ Header: submenuHeader, Submenu: submenuItems }); + }} > {t('Commands')} @@ -126,15 +162,13 @@ export const DefaultAttachmentSelectorComponents = { File({ closeMenu }: AttachmentSelectorActionProps) { const { t } = useTranslationContext(); const { fileInput } = useAttachmentSelectorContext(); - const { isUploadEnabled } = useAttachmentManagerState(); return ( { - if (fileInput) fileInput.click(); + fileInput?.click(); closeMenu(); }} > @@ -178,7 +212,10 @@ export const DefaultAttachmentSelectorComponents = { * Order of AttachmentSelectorAction objects defines the order in the context menu width index 0 being at the top. */ export const defaultAttachmentSelectorActionSet: AttachmentSelectorAction[] = [ - { ActionButton: DefaultAttachmentSelectorComponents.File, type: 'uploadFile' }, + { + ActionButton: DefaultAttachmentSelectorComponents.File, + type: 'uploadFile', + }, { ActionButton: DefaultAttachmentSelectorComponents.Poll, type: 'createPoll', @@ -187,7 +224,11 @@ export const defaultAttachmentSelectorActionSet: AttachmentSelectorAction[] = [ ActionButton: DefaultAttachmentSelectorComponents.Location, type: 'addLocation', }, - { ActionButton: DefaultAttachmentSelectorComponents.Command, type: 'selectCommand' }, + { + ActionButton: DefaultAttachmentSelectorComponents.Command, + Header: CommandsSubmenuHeader, + type: 'selectCommand', + }, ]; export type AttachmentSelectorProps = { @@ -201,35 +242,57 @@ const useAttachmentSelectorActionsFiltered = (original: AttachmentSelectorAction ShareLocationDialog = DefaultLocationDialog, } = useComponentContext(); const { channelCapabilities } = useChannelStateContext(); + const { isUploadEnabled } = useAttachmentManagerState(); const messageComposer = useMessageComposer(); const channelConfig = messageComposer.channel.getConfig(); - return original - .filter((action) => { - if (action.type === 'uploadFile') - return channelCapabilities['upload-file'] && channelConfig?.uploads; + return useMemo( + () => + original + .filter((action) => { + if (action.type === 'uploadFile') + return ( + channelCapabilities['upload-file'] && + channelConfig?.uploads && + isUploadEnabled + ); - if (action.type === 'createPoll') - return ( - channelCapabilities['send-poll'] && - !messageComposer.threadId && - channelConfig?.polls - ); + if (action.type === 'createPoll') + return ( + channelCapabilities['send-poll'] && + !messageComposer.threadId && + channelConfig?.polls + ); - if (action.type === 'addLocation') { - return channelConfig?.shared_locations && !messageComposer.threadId; - } - return true; - }) - .map((action) => { - if (action.type === 'createPoll' && !action.ModalContent) { - return { ...action, ModalContent: PollCreationDialog }; - } - if (action.type === 'addLocation' && !action.ModalContent) { - return { ...action, ModalContent: ShareLocationDialog }; - } - return action; - }); + if (action.type === 'addLocation') { + return channelConfig?.shared_locations && !messageComposer.threadId; + } + + if (action.type === 'selectCommand') { + return !!channelConfig?.commands?.some((command) => !!command.name); + } + + return true; + }) + .map((action) => { + if (action.type === 'createPoll' && !action.ModalContent) { + return { ...action, ModalContent: PollCreationDialog }; + } + if (action.type === 'addLocation' && !action.ModalContent) { + return { ...action, ModalContent: ShareLocationDialog }; + } + return action; + }), + [ + PollCreationDialog, + ShareLocationDialog, + channelCapabilities, + channelConfig, + isUploadEnabled, + messageComposer.threadId, + original, + ], + ); }; export const AttachmentSelector = ({ @@ -251,7 +314,7 @@ export const AttachmentSelector = ({ const [modalContentAction, setModalContentActionAction] = useState(); - const openModal = useCallback( + const openModalForAction = useCallback( (actionType: AttachmentSelectorAction['type']) => { const action = actions.find((a) => a.type === actionType); if (!action?.ModalContent) return; @@ -263,8 +326,48 @@ export const AttachmentSelector = ({ const closeModal = useCallback(() => setModalContentActionAction(undefined), []); const [fileInput, setFileInput] = useState(null); + const [menuLevel, setMenuLevel] = useState(1); const menuButtonRef = useRef(null); + const commandSubmenuItems = useMemo(() => { + const channelConfig = messageComposer.channel.getConfig(); + const commands = (channelConfig?.commands ?? []).filter( + (command): command is CommandResponse & { name: string } => !!command.name, + ); + return commands.map((command) => { + const CommandItem: ContextMenuItemComponent = ({ closeMenu }) => ( + { + messageComposer.textComposer.setCommand(command); + closeMenu(); + }} + /> + ); + return CommandItem; + }); + }, [messageComposer]); + + const contextMenuItems = useMemo( + () => + actions.reduce((acc, action) => { + const submenuItems = + action.type === 'selectCommand' ? commandSubmenuItems : action.Submenu; + const ActionItem = ({ closeMenu, openSubmenu }: ContextMenuItemProps) => ( + + ); + acc.push(ActionItem); + return acc; + }, []), + [actions, commandSubmenuItems, openModalForAction], + ); + const getDefaultPortalDestination = useCallback( () => document.getElementById(CHANNEL_CONTAINER_ID), [], @@ -293,25 +396,23 @@ export const AttachmentSelector = ({ ref={menuButtonRef} /> - {actions.map(({ ActionButton, type }) => ( - - ))} - + items={contextMenuItems} + onClose={menuDialog.close} + onMenuLevelChange={setMenuLevel} + /> = { + ban: IconPeopleRemove, + flag: IconFlag, + giphy: IconGiphy, + mute: IconMute, + unban: IconPeopleAdd, + unmute: IconVolumeFull, +}; + +export const CommandsSubmenuHeader = () => { + const { t } = useTranslationContext(); + const { returnToParentMenu } = useContextMenuContext(); + return ( + + + + {t('Instant commands')} + + + ); +}; + +export const CommandsSubmenu = () => { + const { closeMenu } = useContextMenuContext(); + const messageComposer = useMessageComposer(); + const { textareaRef } = useMessageInputContext(); + const channelConfig = messageComposer.channel.getConfig(); + const commands = useMemo<(CommandResponse & { name: string })[]>( + () => + (channelConfig?.commands ?? []).filter( + (command): command is CommandResponse & { name: string } => !!command.name, + ), + [channelConfig], + ); + + return ( + <> + {commands.map((command) => ( + { + if (!command.name) return; + messageComposer.textComposer.setCommand(command); + textareaRef.current?.focus(); + closeMenu(); + }} + /> + ))} + + ); +}; + +export const useCommandTranslation = (command: CommandResponse) => { + const { t } = useTranslationContext(); + + const knownArgsTranslations = useMemo>( + () => ({ + ban: t('ban-command-args'), + giphy: t('giphy-command-args'), + mute: t('mute-command-args'), + unban: t('unban-command-args'), + unmute: t('unmute-command-args'), + }), + [t], + ); + const knownDescriptionTranslations = useMemo>( + () => ({ + ban: t('ban-command-description'), + giphy: t('giphy-command-description'), + mute: t('mute-command-description'), + unban: t('unban-command-description'), + unmute: t('unmute-command-description'), + }), + [t], + ); + + const args = + command.args && (knownArgsTranslations[command.name ?? ''] ?? t(command.args)); + const description = + command.description && + (knownDescriptionTranslations[command.name ?? ''] ?? t(command.description)); + + return { args, description }; +}; + +export const CommandContextMenuItem = ({ + className, + command, + ...props +}: ComponentProps<'button'> & { + command: CommandResponse & { name: string }; +}) => { + const { args, description } = useCommandTranslation(command); + return ( + + ); +}; diff --git a/src/components/MessageInput/CommandChip.tsx b/src/components/MessageInput/CommandChip.tsx new file mode 100644 index 0000000000..5991576b8b --- /dev/null +++ b/src/components/MessageInput/CommandChip.tsx @@ -0,0 +1,30 @@ +import { useMessageComposer } from './hooks'; +import { useStateStore } from '../../store'; +import type { TextComposerState } from 'stream-chat'; +import { IconCross, IconLightning } from '../Icons'; +import { useMessageInputContext } from '../../context'; + +const textComposerStateSelector = ({ command }: TextComposerState) => ({ command }); + +export const CommandChip = () => { + const { textComposer } = useMessageComposer(); + const { textareaRef } = useMessageInputContext(); + const { command } = useStateStore(textComposer.state, textComposerStateSelector); + if (!command) return null; + + return ( +
+ + {command.name} + +
+ ); +}; diff --git a/src/components/MessageInput/MessageComposerActions.tsx b/src/components/MessageInput/MessageComposerActions.tsx index 00fa885157..4cad184e6b 100644 --- a/src/components/MessageInput/MessageComposerActions.tsx +++ b/src/components/MessageInput/MessageComposerActions.tsx @@ -19,7 +19,8 @@ const messageComposerStateSelector = ({ editedMessage }: MessageComposerState) = editedMessage, }); -const textComposerStateSelector = ({ text }: TextComposerState) => ({ +const textComposerStateSelector = ({ command, text }: TextComposerState) => ({ + command, text, }); @@ -38,7 +39,7 @@ export const MessageComposerActions = () => { messageComposerStateSelector, ); - const { text } = useStateStore( + const { command, text } = useStateStore( messageComposer.textComposer.state, textComposerStateSelector, ); @@ -70,7 +71,7 @@ export const MessageComposerActions = () => { ) : ( - {editedMessage ? : } + {editedMessage || command ? : } ); diff --git a/src/components/MessageInput/MessageInputFlat.tsx b/src/components/MessageInput/MessageInputFlat.tsx index bb2d04a90a..c1e03e6d87 100644 --- a/src/components/MessageInput/MessageInputFlat.tsx +++ b/src/components/MessageInput/MessageInputFlat.tsx @@ -27,6 +27,7 @@ import { type LinkPreviewsManagerState, type MessageComposerState, } from 'stream-chat'; +import { CommandChip as DefaultCommandChip } from './CommandChip'; const messageComposerStateSelector = ({ editedMessage, @@ -110,6 +111,7 @@ export const MessageInputFlat = () => { AdditionalMessageComposerActions = DefaultAdditionalMessageComposerActions, AttachmentSelector = message ? SimpleAttachmentSelector : DefaultAttachmentSelector, AudioRecorder = DefaultAudioRecorder, + CommandChip = DefaultCommandChip, SendToChannelCheckbox = DefaultSendToChannelCheckbox, TextareaComposer = DefaultTextareaComposer, } = useComponentContext(); @@ -124,6 +126,7 @@ export const MessageInputFlat = () => {
+ diff --git a/src/components/MessageInput/index.ts b/src/components/MessageInput/index.ts index 89dd08c533..37be037392 100644 --- a/src/components/MessageInput/index.ts +++ b/src/components/MessageInput/index.ts @@ -9,6 +9,7 @@ export type { UnsupportedAttachmentPreviewProps, VoiceRecordingPreviewProps, } from './AttachmentPreviewList'; +export * from './CommandChip'; export * from './CooldownTimer'; export * from './hooks'; export * from './icons'; diff --git a/src/components/MessageInput/styling/AttachmentPreview.scss b/src/components/MessageInput/styling/AttachmentPreview.scss index 6a04b08f66..30855a9a12 100644 --- a/src/components/MessageInput/styling/AttachmentPreview.scss +++ b/src/components/MessageInput/styling/AttachmentPreview.scss @@ -234,10 +234,16 @@ gap: var(--spacing-xxs); white-space: nowrap; + .str-chat__icon--exclamation-triangle path { + fill: var(--accent-error); + } + .str-chat__attachment-preview-file__retry-upload-button { @include utils.button-reset; - color: var(--str-chat__primary-color); + padding: 0; + color: var(--text-link); cursor: pointer; + line-height: var(--typography-line-height-tight, 16px); } } } diff --git a/src/components/MessageInput/styling/CommandChip.scss b/src/components/MessageInput/styling/CommandChip.scss new file mode 100644 index 0000000000..700fceb2fc --- /dev/null +++ b/src/components/MessageInput/styling/CommandChip.scss @@ -0,0 +1,27 @@ +@use '../../../styling/utils'; + +.str-chat__command-chip { + display: flex; + padding: var(--spacing-xxs) var(--spacing-sm); + justify-content: center; + align-items: center; + gap: var(--spacing-xxs); + border-radius: var(--radius-max); + + color: var(--text-inverse); + background-color: var(--background-core-inverse, var(--slate-900)); + text-transform: uppercase; + font-size: var(--typography-font-size-xs); + font-weight: var(--typography-font-weight-semi-bold); + line-height: var(--typography-line-height-tight); + + .str-chat__command-chip__close-button { + @include utils.button-reset; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + color: var(--text-inverse); + + } +} \ No newline at end of file diff --git a/src/components/MessageInput/styling/CommandsSubmenu.scss b/src/components/MessageInput/styling/CommandsSubmenu.scss new file mode 100644 index 0000000000..79970bed5f --- /dev/null +++ b/src/components/MessageInput/styling/CommandsSubmenu.scss @@ -0,0 +1,5 @@ +.str-chat__context-menu__button--command { + .str-chat__context-menu__button__label { + min-width: 60px; + } +} \ No newline at end of file diff --git a/src/components/MessageInput/styling/MessageComposer.scss b/src/components/MessageInput/styling/MessageComposer.scss index 3adf225c2d..db4f238c2b 100644 --- a/src/components/MessageInput/styling/MessageComposer.scss +++ b/src/components/MessageInput/styling/MessageComposer.scss @@ -98,7 +98,6 @@ display: flex; align-items: center; margin-inline: var(--spacing-xxs) var(--spacing-xs); - min-height: $controls-containers-min-height; // align with the attachment button textarea { @include utils.component-layer-overrides('message-textarea'); diff --git a/src/components/MessageInput/styling/index.scss b/src/components/MessageInput/styling/index.scss index eeed997fb7..1fb2aec7ce 100644 --- a/src/components/MessageInput/styling/index.scss +++ b/src/components/MessageInput/styling/index.scss @@ -1,6 +1,8 @@ @use "AttachmentPreview"; @use "AttachmentPreviewThumbnail"; @use "AttachmentSelector"; +@use "CommandChip"; +@use "CommandsSubmenu"; @use "LinkPreviewList"; @use "MessageComposer"; @use "QuotedMessageIndicator"; diff --git a/src/components/TextareaComposer/SuggestionList/CommandItem.tsx b/src/components/TextareaComposer/SuggestionList/CommandItem.tsx index 659ce15066..39c747d86d 100644 --- a/src/components/TextareaComposer/SuggestionList/CommandItem.tsx +++ b/src/components/TextareaComposer/SuggestionList/CommandItem.tsx @@ -1,49 +1,23 @@ -import type { PropsWithChildren } from 'react'; -import { useMemo } from 'react'; +import type { ComponentProps, PropsWithChildren } from 'react'; import React from 'react'; import type { CommandResponse } from 'stream-chat'; -import { useTranslationContext } from '../../../context'; +import { CommandContextMenuItem } from '../../MessageInput/AttachmentSelector/CommandsSubmenu'; export type CommandItemProps = { entity: CommandResponse; -}; + focused?: boolean; +} & ComponentProps<'button'>; export const CommandItem = (props: PropsWithChildren) => { - const { t } = useTranslationContext(); - const { entity } = props; - const knownArgsTranslations = useMemo>( - () => ({ - ban: t('ban-command-args'), - giphy: t('giphy-command-args'), - mute: t('mute-command-args'), - unban: t('unban-command-args'), - unmute: t('unmute-command-args'), - }), - [t], - ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { entity, focused: _, ...buttonProps } = props; - const knownDescriptionTranslations = useMemo>( - () => ({ - ban: t('ban-command-description'), - giphy: t('giphy-command-description'), - mute: t('mute-command-description'), - unban: t('unban-command-description'), - unmute: t('unmute-command-description'), - }), - [t], - ); + if (!entity.name) return null; return ( -
- - {entity.name}{' '} - {entity.args && (knownArgsTranslations[entity.name ?? ''] ?? t(entity.args))} - -
- - {entity.description && - (knownDescriptionTranslations[entity.name ?? ''] ?? t(entity.description))} - -
+ ); }; diff --git a/src/components/TextareaComposer/SuggestionList/EmoticonItem.tsx b/src/components/TextareaComposer/SuggestionList/EmoticonItem.tsx index 8eacbee578..176b25dd67 100644 --- a/src/components/TextareaComposer/SuggestionList/EmoticonItem.tsx +++ b/src/components/TextareaComposer/SuggestionList/EmoticonItem.tsx @@ -1,4 +1,7 @@ +import clsx from 'clsx'; +import type { ComponentProps } from 'react'; import React from 'react'; +import { EmojiContextMenuButton } from '../../Dialog'; export type EmoticonItemProps = { entity: { @@ -11,32 +14,34 @@ export type EmoticonItemProps = { * */ tokenizedDisplayName: { token: string; parts: string[] }; }; -}; + focused?: boolean; +} & ComponentProps<'button'>; export const EmoticonItem = (props: EmoticonItemProps) => { - const { entity } = props; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { className, entity, focused: _, ...buttonProps } = props; const hasEntity = Object.keys(entity).length; if (!hasEntity) return null; const { parts, token } = entity.tokenizedDisplayName ?? ({} as EmoticonItemProps); - const renderName = () => - parts?.map((part, i) => - part.toLowerCase() === token ? ( - - {part} - - ) : ( - - {part} - - ), - ) ?? null; - return ( -
- {entity.native} - {renderName()} -
+ + {parts?.map((part, i) => + part.toLowerCase() === token ? ( + + {part} + + ) : ( + + {part} + + ), + ) ?? null} + ); }; diff --git a/src/components/TextareaComposer/SuggestionList/SuggestionList.tsx b/src/components/TextareaComposer/SuggestionList/SuggestionList.tsx index 7b6bc45d0f..9fe9c4ece4 100644 --- a/src/components/TextareaComposer/SuggestionList/SuggestionList.tsx +++ b/src/components/TextareaComposer/SuggestionList/SuggestionList.tsx @@ -1,14 +1,19 @@ import clsx from 'clsx'; -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import type { CommandItemProps } from './CommandItem'; import { CommandItem } from './CommandItem'; import type { EmoticonItemProps } from './EmoticonItem'; import { EmoticonItem } from './EmoticonItem'; import type { SuggestionListItemComponentProps } from './SuggestionListItem'; import { SuggestionListItem as DefaultSuggestionListItem } from './SuggestionListItem'; +import type { UserItemProps } from './UserItem'; import { UserItem } from './UserItem'; import { useComponentContext } from '../../../context/ComponentContext'; +import { useMessageInputContext } from '../../../context/MessageInputContext'; import { useStateStore } from '../../../store'; +import { getTextareaCaretRect } from '../../../utils/getTextareaCaretRect'; +import type { ContextMenuItemComponent, ContextMenuItemProps } from '../../Dialog'; +import { ContextMenu } from '../../Dialog'; import { InfiniteScrollPaginator } from '../../InfiniteScrollPaginator/InfiniteScrollPaginator'; import { useMessageComposer } from '../../MessageInput'; import type { @@ -16,7 +21,6 @@ import type { TextComposerState, TextComposerSuggestion, } from 'stream-chat'; -import type { UserItemProps } from './UserItem'; type SuggestionTrigger = '/' | ':' | '@' | string; @@ -32,8 +36,9 @@ export type SuggestionListProps = Partial<{ setFocusedItemIndex: (index: number) => void; }>; -const textComposerStateSelector = (state: TextComposerState) => ({ - suggestions: state.suggestions, +const textComposerStateSelector = ({ selection, suggestions }: TextComposerState) => ({ + selection, + suggestions, }); const searchSourceStateSelector = ( @@ -47,13 +52,13 @@ export const defaultComponents: Record< React.ComponentType > = { '/': (props: SuggestionListItemComponentProps) => ( - + ), ':': (props: SuggestionListItemComponentProps) => ( - + ), '@': (props: SuggestionListItemComponentProps) => ( - + ), } as const; @@ -67,17 +72,61 @@ export const SuggestionList = ({ }: SuggestionListProps) => { const { AutocompleteSuggestionItem = DefaultSuggestionListItem } = useComponentContext(); + const { textareaRef } = useMessageInputContext(); const messageComposer = useMessageComposer(); const { textComposer } = messageComposer; - const { suggestions } = useStateStore(textComposer.state, textComposerStateSelector); + const { selection, suggestions } = useStateStore( + textComposer.state, + textComposerStateSelector, + ); const { items } = useStateStore(suggestions?.searchSource.state, searchSourceStateSelector) ?? {}; const [container, setContainer] = useState(null); + const [containerStyle, setContainerStyle] = useState(); const component = suggestions?.trigger ? suggestionItemComponents[suggestions?.trigger] : undefined; + const contextMenuItems = useMemo(() => { + if (!component) return []; + return (items ?? []).map((item, i) => { + const Item: ContextMenuItemComponent = ({ + closeMenu: _, // eslint-disable-line @typescript-eslint/no-unused-vars + openSubmenu: __, //eslint-disable-line @typescript-eslint/no-unused-vars + ...props + }: ContextMenuItemProps) => ( + setFocusedItemIndex?.(i)} + /> + ); + return Item; + }); + }, [ + items, + component, + focusedItemIndex, + setFocusedItemIndex, + AutocompleteSuggestionItem, + ]); + + const ItemsWrapper = useCallback( + ({ children }: React.ComponentProps<'div'>) => ( + + {children} + + ), + [suggestions?.searchSource.search], + ); + useEffect(() => { if (!closeOnClickOutside || !suggestions || !container) return; const handleClick = (event: MouseEvent) => { @@ -90,34 +139,62 @@ export const SuggestionList = ({ }; }, [closeOnClickOutside, suggestions, container, textComposer]); + const updatePosition = useCallback(() => { + const rect = getTextareaCaretRect(textareaRef.current ?? null, selection?.end); + if (!rect) { + setContainerStyle((prev) => (prev ? undefined : prev)); + return; + } + const containerWidth = container?.getBoundingClientRect().width ?? 0; + const viewportWidth = window.innerWidth || document.documentElement.clientWidth; + const shouldFlipX = containerWidth > 0 && rect.left + containerWidth > viewportWidth; + const left = shouldFlipX ? Math.max(0, rect.left - containerWidth) : rect.left; + const top = rect.top - 8; + setContainerStyle((prev) => { + if (prev && prev.left === left && prev.top === top) return prev; + return { + bottom: 'auto', + left, + position: 'fixed', + top, + transform: 'translateY(-100%)', + zIndex: 1000, + }; + }); + }, [container, selection?.end, textareaRef]); + + useLayoutEffect(() => { + if (!suggestions) return; + updatePosition(); + }, [items?.length, suggestions, updatePosition]); + + useEffect(() => { + if (!suggestions) return; + const textarea = textareaRef.current; + const handleScroll = () => updatePosition(); + window.addEventListener('scroll', handleScroll, true); + window.addEventListener('resize', handleScroll); + textarea?.addEventListener('scroll', handleScroll); + return () => { + window.removeEventListener('scroll', handleScroll, true); + window.removeEventListener('resize', handleScroll); + textarea?.removeEventListener('scroll', handleScroll); + }; + }, [suggestions, textareaRef, updatePosition]); + if (!suggestions || !items?.length || !component) return null; return (
- -
    - {items.map((item, i) => ( - setFocusedItemIndex?.(i)} - /> - ))} -
-
+
); }; diff --git a/src/components/TextareaComposer/SuggestionList/SuggestionListItem.tsx b/src/components/TextareaComposer/SuggestionList/SuggestionListItem.tsx index 78eaefb132..95bf292c68 100644 --- a/src/components/TextareaComposer/SuggestionList/SuggestionListItem.tsx +++ b/src/components/TextareaComposer/SuggestionList/SuggestionListItem.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; -import type { Ref } from 'react'; -import React, { useCallback, useLayoutEffect, useRef } from 'react'; +import { type ComponentProps, useRef } from 'react'; +import React, { useCallback, useLayoutEffect } from 'react'; import { useMessageComposer } from '../../MessageInput'; import type { TextComposerSuggestion } from 'stream-chat'; import type { UserItemProps } from './UserItem'; @@ -16,26 +16,27 @@ export type DefaultSuggestionListItemEntity = export type SuggestionListItemComponentProps = { entity: DefaultSuggestionListItemEntity | unknown; focused: boolean; -}; +} & ComponentProps<'button'>; -export type SuggestionItemProps = { +export type SuggestionItemProps = ComponentProps<'button'> & { component: React.ComponentType; item: TextComposerSuggestion; focused: boolean; - className?: string; - onMouseEnter?: () => void; }; -export const SuggestionListItem = React.forwardRef< - HTMLButtonElement, - SuggestionItemProps ->(function SuggestionListItem( - { className, component: Component, focused, item, onMouseEnter }: SuggestionItemProps, - innerRef: Ref, -) { +export const SuggestionListItem = ({ + className, + component: Component, + focused, + item, + onClick, + onKeyDown, + onMouseEnter, + ...restProps +}: SuggestionItemProps) => { const { textComposer } = useMessageComposer(); const { textareaRef } = useMessageInputContext(); - const containerRef = useRef(null); + const componentRef = useRef(null); const handleSelect = useCallback(() => { textComposer.handleSelect(item); @@ -44,28 +45,27 @@ export const SuggestionListItem = React.forwardRef< useLayoutEffect(() => { if (!focused) return; - containerRef.current?.scrollIntoView({ behavior: 'instant', block: 'nearest' }); - }, [focused, containerRef]); + componentRef.current?.scrollIntoView({ behavior: 'instant', block: 'nearest' }); + }, [focused]); return ( -
  • { + handleSelect(); + onClick?.(e); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') handleSelect(); + onKeyDown?.(event); + }} onMouseEnter={onMouseEnter} - ref={containerRef} - > - -
  • + ref={componentRef} + /> ); -}); +}; diff --git a/src/components/TextareaComposer/SuggestionList/UserItem.tsx b/src/components/TextareaComposer/SuggestionList/UserItem.tsx index cf997c2d11..608e8c970d 100644 --- a/src/components/TextareaComposer/SuggestionList/UserItem.tsx +++ b/src/components/TextareaComposer/SuggestionList/UserItem.tsx @@ -1,8 +1,7 @@ +import type { ComponentProps } from 'react'; import React from 'react'; import clsx from 'clsx'; - -import type { AvatarProps } from '../../Avatar'; -import { Avatar as DefaultAvatar } from '../../Avatar'; +import { UserContextMenuButton } from '../../Dialog'; export type UserItemProps = { /** The user */ @@ -18,19 +17,18 @@ export type UserItemProps = { /** Name of the user */ name?: string; }; - /** Custom UI component to display user avatar, defaults to and accepts same props as: [Avatar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Avatar/Avatar.tsx) */ - Avatar?: React.ComponentType; -}; + focused?: boolean; +} & ComponentProps<'button'>; /** * UI component for mentions rendered in suggestion list */ -export const UserItem = ({ Avatar = DefaultAvatar, entity }: UserItemProps) => { +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const UserItem = ({ entity, focused: _, ...buttonProps }: UserItemProps) => { const hasEntity = !!Object.keys(entity).length; if (!hasEntity) return null; const { parts, token } = entity.tokenizedDisplayName; - const renderName = () => parts.map((part, i) => { const matches = part.toLowerCase() === token; @@ -38,8 +36,8 @@ export const UserItem = ({ Avatar = DefaultAvatar, entity }: UserItemProps) => { return ( @@ -49,17 +47,13 @@ export const UserItem = ({ Avatar = DefaultAvatar, entity }: UserItemProps) => { }); return ( -
    - - - {renderName()} - -
    @
    -
    + + {renderName()} + ); }; diff --git a/src/components/TextareaComposer/TextareaComposer.tsx b/src/components/TextareaComposer/TextareaComposer.tsx index 3b68c67a66..5c15fadf2a 100644 --- a/src/components/TextareaComposer/TextareaComposer.tsx +++ b/src/components/TextareaComposer/TextareaComposer.tsx @@ -15,13 +15,10 @@ import type { SearchSourceState, TextComposerState, } from 'stream-chat'; -import { - useComponentContext, - useMessageInputContext, - useTranslationContext, -} from '../../context'; +import { useComponentContext, useMessageInputContext } from '../../context'; import { useStateStore } from '../../store'; import { SuggestionList as DefaultSuggestionList } from './SuggestionList'; +import { useTextareaPlaceholder } from './hooks/useTextareaPlaceholder'; const textComposerStateSelector = (state: TextComposerState) => ({ selection: state.selection, @@ -83,7 +80,6 @@ export const TextareaComposer = ({ shouldSubmit: shouldSubmitProp, ...restTextareaProps }: TextareaComposerProps) => { - const { t } = useTranslationContext(); const { AutocompleteSuggestionList = DefaultSuggestionList } = useComponentContext(); const { additionalTextareaProps, @@ -97,9 +93,7 @@ export const TextareaComposer = ({ } = useMessageInputContext(); const cooldownRemaining = useCooldownRemaining(); - const placeholder = cooldownRemaining - ? t('Slow mode, wait {{ seconds }}s...', { seconds: cooldownRemaining }) - : (placeholderProp ?? additionalTextareaProps?.placeholder ?? t('Type your message')); + const placeholder = useTextareaPlaceholder({ placeholder: placeholderProp }); const maxRows = maxRowsProp ?? maxRowsContext ?? 1; const minRows = minRowsProp ?? minRowsContext; diff --git a/src/components/TextareaComposer/hooks/index.ts b/src/components/TextareaComposer/hooks/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/TextareaComposer/hooks/useTextareaPlaceholder.ts b/src/components/TextareaComposer/hooks/useTextareaPlaceholder.ts new file mode 100644 index 0000000000..149161e212 --- /dev/null +++ b/src/components/TextareaComposer/hooks/useTextareaPlaceholder.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react'; +import type { TextComposerState } from 'stream-chat'; +import { useMessageInputContext, useTranslationContext } from '../../../context'; +import { useStateStore } from '../../../store'; +import { useCooldownRemaining, useMessageComposer } from '../../MessageInput'; + +type UseTextareaPlaceholderProps = { + placeholder?: string; +}; + +const textComposerStateSelector = ({ command }: TextComposerState) => ({ command }); + +export const useTextareaPlaceholder = ({ + placeholder, +}: UseTextareaPlaceholderProps = {}) => { + const { t } = useTranslationContext(); + const { additionalTextareaProps } = useMessageInputContext(); + const cooldownRemaining = useCooldownRemaining(); + const messageComposer = useMessageComposer(); + const { command } = useStateStore( + messageComposer.textComposer.state, + textComposerStateSelector, + ); + + const knownArgsTranslations = useMemo>( + () => ({ + ban: t('ban-command-args'), + giphy: t('giphy-command-args'), + mute: t('mute-command-args'), + unban: t('unban-command-args'), + unmute: t('unmute-command-args'), + }), + [t], + ); + + const commandArgs = + command?.args && (knownArgsTranslations[command.name ?? ''] ?? t(command.args)); + const commandPlaceholder = + command?.name === 'giphy' ? t('Search GIFs') : (commandArgs ?? undefined); + + const defaultPlaceholder = + placeholder ?? additionalTextareaProps?.placeholder ?? t('Type your message'); + + if (cooldownRemaining) { + return t('Slow mode, wait {{ seconds }}s...', { seconds: cooldownRemaining }); + } + + return commandPlaceholder ?? defaultPlaceholder; +}; diff --git a/src/components/TextareaComposer/styling/SuggestionList.scss b/src/components/TextareaComposer/styling/SuggestionList.scss new file mode 100644 index 0000000000..d0d5ddfd8c --- /dev/null +++ b/src/components/TextareaComposer/styling/SuggestionList.scss @@ -0,0 +1,11 @@ +.str-chat { + .str-chat__suggestion-list { + max-height: 320px; + min-width: 200px; + overflow-y: auto; + + .str-chat__suggestion-list-item--selected { + background-color: var(--str-chat__dialog-menu-button-hover-background-color); + } + } +} \ No newline at end of file diff --git a/src/components/TextareaComposer/styling/index.scss b/src/components/TextareaComposer/styling/index.scss new file mode 100644 index 0000000000..b6760607e1 --- /dev/null +++ b/src/components/TextareaComposer/styling/index.scss @@ -0,0 +1 @@ +@use 'SuggestionList'; \ No newline at end of file diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index f1ada78c93..7a3b3f4f8e 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -95,6 +95,8 @@ export type ComponentContextValue = { CalloutDialog?: React.ComponentType; /** Custom UI component to display set of action buttons within `ChannelPreviewMessenger` component, accepts same props as: [ChannelPreviewActionButtons](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelList/ChannelPreviewActionButtons.tsx) */ ChannelPreviewActionButtons?: React.ComponentType; + /** Custom UI component to display command chip, defaults to and accepts same props as: [CommandChip](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/CommandChip.tsx) */ + CommandChip?: React.ComponentType; /** Custom UI component to display the slow mode cooldown timer, defaults to and accepts same props as: [CooldownTimer](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/CooldownTimer.tsx) */ CooldownTimer?: React.ComponentType; /** Custom UI component to render set of buttons to be displayed in the MessageActionsBox, defaults to and accepts same props as: [CustomMessageActionsList](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageActions/CustomMessageActionsList.tsx) */ diff --git a/src/i18n/de.json b/src/i18n/de.json index e3748fe371..870c5ea360 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -73,6 +73,7 @@ "Attach files": "Dateien anhängen", "Attachment upload blocked due to {{reason}}": "Anhang-Upload blockiert wegen {{reason}}", "Attachment upload failed due to {{reason}}": "Anhang-Upload fehlgeschlagen wegen {{reason}}", + "Back": "Back", "ban-command-args": "[@Benutzername] [Text]", "ban-command-description": "Einen Benutzer verbannen", "Cancel": "Abbrechen", @@ -136,6 +137,7 @@ "Generating...": "Generieren...", "giphy-command-args": "[Text]", "giphy-command-description": "Poste ein zufälliges Gif in den Kanal", + "Instant commands": "Instant commands", "Latest Messages": "Neueste Nachrichten", "live": "live", "Live for {{duration}}": "Live für {{duration}}", @@ -196,6 +198,7 @@ "Save for later": "Für später speichern", "Saved for later": "Für später gespeichert", "Search": "Suche", + "Search GIFs": "Search GIFs", "search-results-header-filter-source-button-label--channels": "Kanäle", "search-results-header-filter-source-button-label--messages": "Nachrichten", "search-results-header-filter-source-button-label--users": "Benutzer", diff --git a/src/i18n/en.json b/src/i18n/en.json index 407480ba5c..b84eb8003b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -73,6 +73,7 @@ "Attach files": "Attach files", "Attachment upload blocked due to {{reason}}": "Attachment upload blocked due to {{reason}}", "Attachment upload failed due to {{reason}}": "Attachment upload failed due to {{reason}}", + "Back": "Back", "ban-command-args": "[@username] [text]", "ban-command-description": "Ban a user", "Cancel": "Cancel", @@ -136,6 +137,7 @@ "Generating...": "Generating...", "giphy-command-args": "[text]", "giphy-command-description": "Post a random gif to the channel", + "Instant commands": "Instant commands", "Latest Messages": "Latest Messages", "live": "live", "Live for {{duration}}": "Live for {{duration}}", @@ -196,6 +198,7 @@ "Save for later": "Save for later", "Saved for later": "Saved for later", "Search": "Search", + "Search GIFs": "Search GIFs", "search-results-header-filter-source-button-label--channels": "channels", "search-results-header-filter-source-button-label--messages": "messages", "search-results-header-filter-source-button-label--users": "users", diff --git a/src/i18n/es.json b/src/i18n/es.json index 5056693212..068910dbc3 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -78,6 +78,7 @@ "Attach files": "Adjuntar archivos", "Attachment upload blocked due to {{reason}}": "Carga de adjunto bloqueada debido a {{reason}}", "Attachment upload failed due to {{reason}}": "Carga de adjunto fallida debido a {{reason}}", + "Back": "Atrás", "ban-command-args": "[@usuario] [texto]", "ban-command-description": "Prohibir a un usuario", "Cancel": "Cancelar", @@ -141,6 +142,7 @@ "Generating...": "Generando...", "giphy-command-args": "[texto]", "giphy-command-description": "Publicar un gif aleatorio en el canal", + "Instant commands": "Comandos instantáneos", "Latest Messages": "Últimos mensajes", "live": "En vivo", "Live for {{duration}}": "En vivo durante {{duration}}", @@ -202,6 +204,7 @@ "Save for later": "Guardar para más tarde", "Saved for later": "Guardado para más tarde", "Search": "Buscar", + "Search GIFs": "Buscar GIFs", "search-results-header-filter-source-button-label--channels": "canales", "search-results-header-filter-source-button-label--messages": "mensajes", "search-results-header-filter-source-button-label--users": "usuarios", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index d15114118c..524b454b74 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -78,6 +78,7 @@ "Attach files": "Joindre des fichiers", "Attachment upload blocked due to {{reason}}": "Téléchargement de pièce jointe bloqué en raison de {{reason}}", "Attachment upload failed due to {{reason}}": "Échec du téléchargement de la pièce jointe en raison de {{reason}}", + "Back": "Retour", "ban-command-args": "[@nomdutilisateur] [texte]", "ban-command-description": "Bannir un utilisateur", "Cancel": "Annuler", @@ -141,6 +142,7 @@ "Generating...": "Génération...", "giphy-command-args": "[texte]", "giphy-command-description": "Poster un GIF aléatoire dans le canal", + "Instant commands": "Commandes instantanées", "Latest Messages": "Derniers messages", "live": "en direct", "Live for {{duration}}": "En direct pendant {{duration}}", @@ -202,6 +204,7 @@ "Save for later": "Enregistrer pour plus tard", "Saved for later": "Enregistré pour plus tard", "Search": "Rechercher", + "Search GIFs": "Rechercher des GIFs", "search-results-header-filter-source-button-label--channels": "canaux", "search-results-header-filter-source-button-label--messages": "messages", "search-results-header-filter-source-button-label--users": "utilisateurs", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index afe30e710f..507ddbd302 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -73,6 +73,7 @@ "Attach files": "फाइल्स अटैच करे", "Attachment upload blocked due to {{reason}}": "{{reason}} के कारण अटैचमेंट अपलोड ब्लॉक किया गया", "Attachment upload failed due to {{reason}}": "{{reason}} के कारण अटैचमेंट अपलोड विफल रहा", + "Back": "वापस", "ban-command-args": "[@उपयोगकर्तनाम] [पाठ]", "ban-command-description": "एक उपयोगकर्ता को प्रतिषेधित करें", "Cancel": "रद्द करें", @@ -137,6 +138,7 @@ "Generating...": "बना रहा है...", "giphy-command-args": "[पाठ]", "giphy-command-description": "चैनल पर एक क्रॉफिल जीआइएफ पोस्ट करें", + "Instant commands": "तत्काल कमांड", "Latest Messages": "नवीनतम संदेश", "live": "लाइव", "Live for {{duration}}": "{{duration}} के लिए लाइव", @@ -197,6 +199,7 @@ "Save for later": "बाद के लिए सहेजें", "Saved for later": "बाद के लिए सहेजा गया", "Search": "खोज", + "Search GIFs": "GIF खोजें", "search-results-header-filter-source-button-label--channels": "चैनल्स", "search-results-header-filter-source-button-label--messages": "संदेश", "search-results-header-filter-source-button-label--users": "उपयोगकर्ता", diff --git a/src/i18n/it.json b/src/i18n/it.json index c650d9005f..c075f26d83 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -78,6 +78,7 @@ "Attach files": "Allega file", "Attachment upload blocked due to {{reason}}": "Caricamento allegato bloccato a causa di {{reason}}", "Attachment upload failed due to {{reason}}": "Caricamento allegato fallito a causa di {{reason}}", + "Back": "Indietro", "ban-command-args": "[@nomeutente] [testo]", "ban-command-description": "Vietare un utente", "Cancel": "Annulla", @@ -141,6 +142,7 @@ "Generating...": "Generando...", "giphy-command-args": "[testo]", "giphy-command-description": "Pubblica un gif casuale sul canale", + "Instant commands": "Comandi istantanei", "Latest Messages": "Ultimi messaggi", "live": "live", "Live for {{duration}}": "Live per {{duration}}", @@ -202,6 +204,7 @@ "Save for later": "Salva per dopo", "Saved for later": "Salvato per dopo", "Search": "Cerca", + "Search GIFs": "Cerca GIF", "search-results-header-filter-source-button-label--channels": "canali", "search-results-header-filter-source-button-label--messages": "messaggi", "search-results-header-filter-source-button-label--users": "utenti", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 32ec130d5f..0a5cd254d3 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -68,6 +68,7 @@ "Attach files": "ファイルを添付する", "Attachment upload blocked due to {{reason}}": "{{reason}}のため添付ファイルのアップロードがブロックされました", "Attachment upload failed due to {{reason}}": "{{reason}}のため添付ファイルのアップロードに失敗しました", + "Back": "戻る", "ban-command-args": "[@ユーザ名] [テキスト]", "ban-command-description": "ユーザーを禁止する", "Cancel": "キャンセル", @@ -131,6 +132,7 @@ "Generating...": "生成中...", "giphy-command-args": "[テキスト]", "giphy-command-description": "チャンネルにランダムなGIFを投稿する", + "Instant commands": "インスタントコマンド", "Latest Messages": "最新のメッセージ", "live": "ライブ", "Live for {{duration}}": "{{duration}}間ライブ", @@ -191,6 +193,7 @@ "Save for later": "後で保存", "Saved for later": "後で保存済み", "Search": "探す", + "Search GIFs": "GIFを検索", "search-results-header-filter-source-button-label--channels": "チャンネル", "search-results-header-filter-source-button-label--messages": "メッセージ", "search-results-header-filter-source-button-label--users": "ユーザー", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 089a6c185b..020a40a815 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -68,6 +68,7 @@ "Attach files": "파일 첨부", "Attachment upload blocked due to {{reason}}": "{{reason}}로 인해 첨부 파일 업로드가 차단되었습니다", "Attachment upload failed due to {{reason}}": "{{reason}}로 인해 첨부 파일 업로드가 실패했습니다", + "Back": "뒤로", "ban-command-args": "[@사용자이름] [텍스트]", "ban-command-description": "사용자를 차단", "Cancel": "취소", @@ -131,6 +132,7 @@ "Generating...": "생성 중...", "giphy-command-args": "[텍스트]", "giphy-command-description": "채널에 무작위 GIF 게시", + "Instant commands": "즉시 명령어", "Latest Messages": "최신 메시지", "live": "라이브", "Live for {{duration}}": "{{duration}} 동안 라이브", @@ -191,6 +193,7 @@ "Save for later": "나중에 저장", "Saved for later": "나중에 저장됨", "Search": "찾다", + "Search GIFs": "GIF 검색", "search-results-header-filter-source-button-label--channels": "채널", "search-results-header-filter-source-button-label--messages": "메시지", "search-results-header-filter-source-button-label--users": "사용자", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 199a6b69fc..9ed8bf85bd 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -73,6 +73,7 @@ "Attach files": "Bijlage toevoegen", "Attachment upload blocked due to {{reason}}": "Bijlage upload geblokkeerd vanwege {{reason}}", "Attachment upload failed due to {{reason}}": "Bijlage upload mislukt vanwege {{reason}}", + "Back": "Terug", "ban-command-args": "[@gebruikersnaam] [tekst]", "ban-command-description": "Een gebruiker verbannen", "Cancel": "Annuleer", @@ -136,6 +137,7 @@ "Generating...": "Genereren...", "giphy-command-args": "[tekst]", "giphy-command-description": "Plaats een willekeurige gif in het kanaal", + "Instant commands": "Snelle opdrachten", "Latest Messages": "Laatste berichten", "live": "live", "Live for {{duration}}": "Live voor {{duration}}", @@ -196,6 +198,7 @@ "Save for later": "Bewaren voor later", "Saved for later": "Bewaard voor later", "Search": "Zoeken", + "Search GIFs": "GIF's zoeken", "search-results-header-filter-source-button-label--channels": "kanalen", "search-results-header-filter-source-button-label--messages": "berichten", "search-results-header-filter-source-button-label--users": "gebruikers", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 086e30ebcb..0799407765 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -78,6 +78,7 @@ "Attach files": "Anexar arquivos", "Attachment upload blocked due to {{reason}}": "Upload de anexo bloqueado devido a {{reason}}", "Attachment upload failed due to {{reason}}": "Upload de anexo falhou devido a {{reason}}", + "Back": "Voltar", "ban-command-args": "[@nomedeusuário] [texto]", "ban-command-description": "Banir um usuário", "Cancel": "Cancelar", @@ -141,6 +142,7 @@ "Generating...": "Gerando...", "giphy-command-args": "[texto]", "giphy-command-description": "Postar um gif aleatório no canal", + "Instant commands": "Comandos instantâneos", "Latest Messages": "Mensagens mais recentes", "live": "ao vivo", "Live for {{duration}}": "Ao vivo por {{duration}}", @@ -202,6 +204,7 @@ "Save for later": "Salvar para depois", "Saved for later": "Salvo para depois", "Search": "Buscar", + "Search GIFs": "Pesquisar GIFs", "search-results-header-filter-source-button-label--channels": "canais", "search-results-header-filter-source-button-label--messages": "mensagens", "search-results-header-filter-source-button-label--users": "usuários", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index c7236b2170..61416666e4 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -83,6 +83,7 @@ "Attach files": "Прикрепить файлы", "Attachment upload blocked due to {{reason}}": "Загрузка вложения заблокирована из-за {{reason}}", "Attachment upload failed due to {{reason}}": "Загрузка вложения не удалась из-за {{reason}}", + "Back": "Назад", "ban-command-args": "[@имяпользователя] [текст]", "ban-command-description": "Заблокировать пользователя", "Cancel": "Отмена", @@ -146,6 +147,7 @@ "Generating...": "Генерирую...", "giphy-command-args": "[текст]", "giphy-command-description": "Опубликовать случайную GIF-анимацию в канале", + "Instant commands": "Мгновенные команды", "Latest Messages": "Последние сообщения", "live": "В прямом эфире", "Live for {{duration}}": "В прямом эфире {{duration}}", @@ -208,6 +210,7 @@ "Save for later": "Сохранить на потом", "Saved for later": "Сохранено на потом", "Search": "Поиск", + "Search GIFs": "Поиск GIF", "search-results-header-filter-source-button-label--channels": "каналы", "search-results-header-filter-source-button-label--messages": "сообщения", "search-results-header-filter-source-button-label--users": "пользователи", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index d0d926fcd5..d61db148a2 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -73,6 +73,7 @@ "Attach files": "Dosya ekle", "Attachment upload blocked due to {{reason}}": "{{reason}} nedeniyle ek yükleme engellendi", "Attachment upload failed due to {{reason}}": "{{reason}} nedeniyle ek yükleme başarısız oldu", + "Back": "Geri", "ban-command-args": "[@kullanıcıadı] [metin]", "ban-command-description": "Bir kullanıcıyı yasakla", "Cancel": "İptal", @@ -136,6 +137,7 @@ "Generating...": "Oluşturuluyor...", "giphy-command-args": "[metin]", "giphy-command-description": "Rastgele bir gif'i kanala gönder", + "Instant commands": "Anlık komutlar", "Latest Messages": "Son Mesajlar", "live": "canlı", "Live for {{duration}}": "{{duration}} boyunca canlı", @@ -196,6 +198,7 @@ "Save for later": "Daha sonra kaydet", "Saved for later": "Daha sonra kaydedildi", "Search": "Arama", + "Search GIFs": "GIF ara", "search-results-header-filter-source-button-label--channels": "kanallar", "search-results-header-filter-source-button-label--messages": "mesajlar", "search-results-header-filter-source-button-label--users": "kullanıcılar", diff --git a/src/styling/index.scss b/src/styling/index.scss index 06dc366361..7d90d3136b 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -21,11 +21,12 @@ // Specific components @use "../components/Attachment/styling" as Attachment; @use "../components/AudioPlayback/styling" as AudioPlayback; +@use "../components/Avatar/styling/Avatar" as Avatar; @use "../components/MediaRecorder/AudioRecorder/styling" as AudioRecorder; @use "../components/Message/styling" as Message; @use "../components/MessageInput/styling" as MessageComposer; @use "../components/Reactions/styling/ReactionSelector" as ReactionSelector; -@use "../components/Avatar/styling/Avatar" as Avatar; +@use "../components/TextareaComposer/styling/" as TextareaComposer; // Layers have to be kept the last @import 'modern-normalize' layer(css-reset); diff --git a/src/utils/index.ts b/src/utils/index.ts index 2b543055e3..a6f6751bfb 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './getChannel'; +export * from './getTextareaCaretRect'; export * from './getWholeChar'; From 2d3c19a189047107e6b71eaf057637f491c9c99b Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Feb 2026 11:16:38 +0100 Subject: [PATCH 2/8] refactor: use usePopoverPosition hook to position the SuggestionList --- src/components/Avatar/Avatar.tsx | 4 +- .../Dialog/hooks/usePopoverPosition.ts | 8 +- .../SuggestionList/SuggestionList.tsx | 102 ++++++++++-------- 3 files changed, 68 insertions(+), 46 deletions(-) diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index f2ccac4dd5..4403b606fc 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -102,9 +102,7 @@ export const Avatar = ({ {sizeAwareInitials}
    )} - {!sizeAwareInitials.length && ( - - )} + {!sizeAwareInitials.length && } )}
    diff --git a/src/components/Dialog/hooks/usePopoverPosition.ts b/src/components/Dialog/hooks/usePopoverPosition.ts index 57708bad70..c152560690 100644 --- a/src/components/Dialog/hooks/usePopoverPosition.ts +++ b/src/components/Dialog/hooks/usePopoverPosition.ts @@ -42,6 +42,8 @@ export type UsePopoverParams = { allowFlip?: boolean; /** Keep in viewport; default true to match common popper setups */ allowShift?: boolean; + /** Extra options for Floating UI shift() middleware (merged with default padding: 8) */ + shiftOptions?: Parameters[0]; /** The floating UI is fitted to the available space (by constraining its max size) instead of letting it overflow; default false */ fitAvailableSpace?: boolean; /** Offset (number, object, or [crossAxis, mainAxis] tuple) */ @@ -66,10 +68,14 @@ export function usePopoverPosition({ freeze = false, offset, placement = 'bottom-start', + shiftOptions, }: UsePopoverParams) { const autoMw = autoMiddlewareFor(placement); const offsetMiddleware = toOffsetMw(offset); const isSidePlacement = placement.startsWith('left') || placement.startsWith('right'); + const mergedShiftOptions = shiftOptions + ? { padding: 8, ...shiftOptions } + : { padding: 8 }; const middleware = [ // offset first (mirrors common Popper setups) @@ -80,7 +86,7 @@ export function usePopoverPosition({ ...(autoMw ? [autoMw] : allowFlip && !isSidePlacement ? [flipMw()] : []), // viewport collision adjustments - ...(allowShift ? [shiftMw({ padding: 8 })] : []), + ...(allowShift ? [shiftMw(mergedShiftOptions)] : []), // optional size constraining // eslint-disable-next-line @typescript-eslint/no-empty-function diff --git a/src/components/TextareaComposer/SuggestionList/SuggestionList.tsx b/src/components/TextareaComposer/SuggestionList/SuggestionList.tsx index 9fe9c4ece4..b6145c6c5c 100644 --- a/src/components/TextareaComposer/SuggestionList/SuggestionList.tsx +++ b/src/components/TextareaComposer/SuggestionList/SuggestionList.tsx @@ -1,5 +1,13 @@ import clsx from 'clsx'; -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { VirtualElement } from '@floating-ui/react'; import type { CommandItemProps } from './CommandItem'; import { CommandItem } from './CommandItem'; import type { EmoticonItemProps } from './EmoticonItem'; @@ -14,6 +22,7 @@ import { useStateStore } from '../../../store'; import { getTextareaCaretRect } from '../../../utils/getTextareaCaretRect'; import type { ContextMenuItemComponent, ContextMenuItemProps } from '../../Dialog'; import { ContextMenu } from '../../Dialog'; +import { usePopoverPosition } from '../../Dialog/hooks/usePopoverPosition'; import { InfiniteScrollPaginator } from '../../InfiniteScrollPaginator/InfiniteScrollPaginator'; import { useMessageComposer } from '../../MessageInput'; import type { @@ -81,8 +90,23 @@ export const SuggestionList = ({ ); const { items } = useStateStore(suggestions?.searchSource.state, searchSourceStateSelector) ?? {}; + const [container, setContainer] = useState(null); - const [containerStyle, setContainerStyle] = useState(); + const caretRectRef = useRef(null); + const virtualCaretReference = useMemo( + () => ({ + getBoundingClientRect: () => caretRectRef.current ?? new DOMRect(), + }), + [], + ); + + const { refs, strategy, update, x, y } = usePopoverPosition({ + allowFlip: false, + offset: 8, + placement: 'top-start', + // For top placements, the cross-axis is X; we need this to allow flipping the list to the right when it overflows the right edge. + shiftOptions: { crossAxis: true }, + }); const component = suggestions?.trigger ? suggestionItemComponents[suggestions?.trigger] @@ -139,48 +163,36 @@ export const SuggestionList = ({ }; }, [closeOnClickOutside, suggestions, container, textComposer]); - const updatePosition = useCallback(() => { - const rect = getTextareaCaretRect(textareaRef.current ?? null, selection?.end); - if (!rect) { - setContainerStyle((prev) => (prev ? undefined : prev)); - return; - } - const containerWidth = container?.getBoundingClientRect().width ?? 0; - const viewportWidth = window.innerWidth || document.documentElement.clientWidth; - const shouldFlipX = containerWidth > 0 && rect.left + containerWidth > viewportWidth; - const left = shouldFlipX ? Math.max(0, rect.left - containerWidth) : rect.left; - const top = rect.top - 8; - setContainerStyle((prev) => { - if (prev && prev.left === left && prev.top === top) return prev; - return { - bottom: 'auto', - left, - position: 'fixed', - top, - transform: 'translateY(-100%)', - zIndex: 1000, - }; - }); - }, [container, selection?.end, textareaRef]); + useEffect(() => { + refs.setFloating(container); + }, [container, refs]); useLayoutEffect(() => { - if (!suggestions) return; - updatePosition(); - }, [items?.length, suggestions, updatePosition]); - - useEffect(() => { - if (!suggestions) return; - const textarea = textareaRef.current; - const handleScroll = () => updatePosition(); - window.addEventListener('scroll', handleScroll, true); - window.addEventListener('resize', handleScroll); - textarea?.addEventListener('scroll', handleScroll); - return () => { - window.removeEventListener('scroll', handleScroll, true); - window.removeEventListener('resize', handleScroll); - textarea?.removeEventListener('scroll', handleScroll); + if (!suggestions || !update) return; + const updatePosition = () => { + const rect = getTextareaCaretRect(textareaRef.current ?? null, selection?.end); + if (!rect) { + caretRectRef.current = null; + refs.setReference(null); + return; + } + caretRectRef.current = rect; + virtualCaretReference.contextElement = textareaRef.current ?? undefined; + refs.setReference(virtualCaretReference); + update(); }; - }, [suggestions, textareaRef, updatePosition]); + + updatePosition(); + }, [ + container, + items?.length, + refs, + selection?.end, + suggestions, + textareaRef, + update, + virtualCaretReference, + ]); if (!suggestions || !items?.length || !component) return null; @@ -188,7 +200,13 @@ export const SuggestionList = ({
    Date: Thu, 5 Feb 2026 11:53:45 +0100 Subject: [PATCH 3/8] refactor: use CommandsSubmenu component in AttachmentSelector --- src/components/Dialog/base/ContextMenu.tsx | 9 +++-- .../AttachmentSelector/AttachmentSelector.tsx | 37 +++---------------- .../AttachmentSelector/CommandsSubmenu.tsx | 3 +- 3 files changed, 14 insertions(+), 35 deletions(-) diff --git a/src/components/Dialog/base/ContextMenu.tsx b/src/components/Dialog/base/ContextMenu.tsx index 5cd679d6d7..0aae4a9dcc 100644 --- a/src/components/Dialog/base/ContextMenu.tsx +++ b/src/components/Dialog/base/ContextMenu.tsx @@ -54,7 +54,7 @@ export const ContextMenuRoot = React.forwardRef { type ContextMenuLevel = { items?: ContextMenuItemComponent[]; + Submenu?: ContextMenuSubmenu; Header?: ContextMenuHeaderComponent; ItemsWrapper?: ComponentType>; }; @@ -135,8 +136,8 @@ export const ContextMenu = ({ }: ContextMenuOpenSubmenuParams) => { const nextLevel: ContextMenuLevel = { Header, - items: Submenu, ItemsWrapper: SubmenuItemsWrapper ?? ItemsWrapper, + Submenu, }; setMenuStack((current) => [...current, nextLevel]); }, @@ -175,7 +176,9 @@ export const ContextMenu = ({ ))} - {activeMenu.ItemsWrapper ? ( + {activeMenu.Submenu ? ( + + ) : activeMenu.ItemsWrapper ? ( {activeMenu.items?.map((Item, index) => ( { const { AttachmentSelectorInitiationButtonContents } = useComponentContext(); @@ -139,18 +138,13 @@ export type AttachmentSelectorActionProps = { export const DefaultAttachmentSelectorComponents = { Command({ openSubmenu, submenuHeader, submenuItems }: AttachmentSelectorActionProps) { const { t } = useTranslationContext(); + const hasSubmenu = !!submenuItems; return ( 0) || - (!Array.isArray(submenuItems) && !!submenuItems) - } + hasSubMenu={hasSubmenu} Icon={IconCommand} onClick={() => { - const hasSubmenu = - (Array.isArray(submenuItems) && submenuItems.length > 0) || - (!Array.isArray(submenuItems) && !!submenuItems); if (!hasSubmenu) return; openSubmenu({ Header: submenuHeader, Submenu: submenuItems }); }} @@ -227,6 +221,7 @@ export const defaultAttachmentSelectorActionSet: AttachmentSelectorAction[] = [ { ActionButton: DefaultAttachmentSelectorComponents.Command, Header: CommandsSubmenuHeader, + Submenu: CommandsSubmenu, type: 'selectCommand', }, ]; @@ -329,30 +324,10 @@ export const AttachmentSelector = ({ const [menuLevel, setMenuLevel] = useState(1); const menuButtonRef = useRef(null); - const commandSubmenuItems = useMemo(() => { - const channelConfig = messageComposer.channel.getConfig(); - const commands = (channelConfig?.commands ?? []).filter( - (command): command is CommandResponse & { name: string } => !!command.name, - ); - return commands.map((command) => { - const CommandItem: ContextMenuItemComponent = ({ closeMenu }) => ( - { - messageComposer.textComposer.setCommand(command); - closeMenu(); - }} - /> - ); - return CommandItem; - }); - }, [messageComposer]); - const contextMenuItems = useMemo( () => actions.reduce((acc, action) => { - const submenuItems = - action.type === 'selectCommand' ? commandSubmenuItems : action.Submenu; + const submenuItems = action.Submenu; const ActionItem = ({ closeMenu, openSubmenu }: ContextMenuItemProps) => ( { onClick={() => { if (!command.name) return; messageComposer.textComposer.setCommand(command); - textareaRef.current?.focus(); closeMenu(); + // Defer the focus to the next frame so it wins over FocusScope's restore-to-attachment-selector-button behavior. + requestAnimationFrame(() => textareaRef.current?.focus()); }} /> ))} From ab09871194b3bf8aa95853fccb0237c1a2e9104a Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Feb 2026 12:10:12 +0100 Subject: [PATCH 4/8] feat: add getTextareaCaretRect to measure caret position --- src/utils/getTextareaCaretRect.ts | 59 +++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/utils/getTextareaCaretRect.ts diff --git a/src/utils/getTextareaCaretRect.ts b/src/utils/getTextareaCaretRect.ts new file mode 100644 index 0000000000..ad43cb4367 --- /dev/null +++ b/src/utils/getTextareaCaretRect.ts @@ -0,0 +1,59 @@ +const CARET_MIRROR_CLASS = 'str-chat__textarea-caret-mirror'; +const CARET_MARKER_CLASS = 'str-chat__textarea-caret-marker'; + +export type TextareaCaretRect = DOMRect | null; + +/** + * Returns the caret rectangle for a textarea using a mirror-measure hack. + * It clones computed styles and content into a hidden element to infer the + * caret position because textarea doesn't expose caret geometry. + */ + +export const getTextareaCaretRect = ( + textarea: HTMLTextAreaElement | null, + selectionEnd?: number, +): TextareaCaretRect => { + if (!textarea || typeof window === 'undefined') return null; + + const caretIndex = Math.max(0, selectionEnd ?? textarea.selectionEnd ?? 0); + const value = textarea.value ?? ''; + const valueBeforeCaret = value.slice(0, caretIndex); + const valueAfterCaret = value.slice(caretIndex); + + const computedStyle = window.getComputedStyle(textarea); + const mirror = document.createElement('div'); + mirror.className = CARET_MIRROR_CLASS; + mirror.style.position = 'absolute'; + mirror.style.visibility = 'hidden'; + mirror.style.top = '0'; + mirror.style.left = '-9999px'; + mirror.style.whiteSpace = 'pre-wrap'; + mirror.style.wordWrap = 'break-word'; + + for (const property of computedStyle) { + mirror.style.setProperty(property, computedStyle.getPropertyValue(property)); + } + + mirror.textContent = valueBeforeCaret; + + const caretMarker = document.createElement('span'); + caretMarker.className = CARET_MARKER_CLASS; + caretMarker.textContent = valueAfterCaret.length ? valueAfterCaret : '.'; + mirror.appendChild(caretMarker); + + document.body.appendChild(mirror); + mirror.scrollTop = textarea.scrollTop; + mirror.scrollLeft = textarea.scrollLeft; + + const textareaRect = textarea.getBoundingClientRect(); + const mirrorRect = mirror.getBoundingClientRect(); + const caretRect = caretMarker.getBoundingClientRect(); + + document.body.removeChild(mirror); + + const left = textareaRect.left + (caretRect.left - mirrorRect.left); + const top = textareaRect.top + (caretRect.top - mirrorRect.top); + const height = caretRect.height || parseFloat(computedStyle.lineHeight) || 0; + + return new DOMRect(left, top, 0, height); +}; From 842ad44ce5a22d332d77a4f7b4465971e3fff891 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Feb 2026 13:34:46 +0100 Subject: [PATCH 5/8] refactor: add header to all the command menus --- src/components/Dialog/base/ContextMenu.tsx | 32 +++++++++++-------- .../Dialog/styling/ContextMenu.scss | 5 ++- src/components/Icons/styling/BaseIcon.scss | 3 ++ src/components/Icons/styling/index.scss | 1 + .../AttachmentSelector/AttachmentSelector.tsx | 14 ++++++-- .../{CommandsSubmenu.tsx => CommandsMenu.tsx} | 21 ++++++++++-- .../MessageInput/styling/CommandsMenu.scss | 24 ++++++++++++++ .../MessageInput/styling/CommandsSubmenu.scss | 5 --- .../MessageInput/styling/index.scss | 2 +- .../SuggestionList/CommandItem.tsx | 2 +- .../SuggestionList/SuggestionList.tsx | 10 ++++++ 11 files changed, 92 insertions(+), 27 deletions(-) create mode 100644 src/components/Icons/styling/BaseIcon.scss rename src/components/MessageInput/AttachmentSelector/{CommandsSubmenu.tsx => CommandsMenu.tsx} (85%) create mode 100644 src/components/MessageInput/styling/CommandsMenu.scss delete mode 100644 src/components/MessageInput/styling/CommandsSubmenu.scss diff --git a/src/components/Dialog/base/ContextMenu.tsx b/src/components/Dialog/base/ContextMenu.tsx index 0aae4a9dcc..0bb89fc945 100644 --- a/src/components/Dialog/base/ContextMenu.tsx +++ b/src/components/Dialog/base/ContextMenu.tsx @@ -60,6 +60,7 @@ export type ContextMenuOpenSubmenuParams = { Submenu: ContextMenuSubmenu; Header?: ContextMenuHeaderComponent; ItemsWrapper?: ComponentType>; + menuClassName?: string; }; export type ContextMenuItemProps = ComponentProps<'button'> & { @@ -92,6 +93,7 @@ type ContextMenuLevel = { Submenu?: ContextMenuSubmenu; Header?: ContextMenuHeaderComponent; ItemsWrapper?: ComponentType>; + menuClassName?: string; }; export type ContextMenuProps = Omit, 'children'> & { @@ -99,6 +101,7 @@ export type ContextMenuProps = Omit, 'children'> & { items: ContextMenuItemComponent[]; Header?: ContextMenuHeaderComponent; ItemsWrapper?: ComponentType>; + menuClassName?: string; onClose?: () => void; onMenuLevelChange?: (level: number) => void; }; @@ -109,6 +112,7 @@ export const ContextMenu = ({ Header, items, ItemsWrapper, + menuClassName, onClose, onMenuLevelChange, ...props @@ -118,8 +122,9 @@ export const ContextMenu = ({ Header, items, ItemsWrapper, + menuClassName, }), - [Header, items, ItemsWrapper], + [Header, items, ItemsWrapper, menuClassName], ); const [menuStack, setMenuStack] = useState(() => [rootLevel]); const activeMenu = menuStack[menuStack.length - 1]; @@ -132,11 +137,13 @@ export const ContextMenu = ({ ({ Header, ItemsWrapper: SubmenuItemsWrapper, + menuClassName, Submenu, }: ContextMenuOpenSubmenuParams) => { const nextLevel: ContextMenuLevel = { Header, ItemsWrapper: SubmenuItemsWrapper ?? ItemsWrapper, + menuClassName, Submenu, }; setMenuStack((current) => [...current, nextLevel]); @@ -163,18 +170,17 @@ export const ContextMenu = ({ return ( - - {menuStack.length > 1 && - (activeMenu.Header ? ( - - ) : ( - - - - {backLabel} - - - ))} + + {activeMenu.Header ? ( + + ) : menuStack.length > 1 ? ( + + + + {backLabel} + + + ) : null} {activeMenu.Submenu ? ( diff --git a/src/components/Dialog/styling/ContextMenu.scss b/src/components/Dialog/styling/ContextMenu.scss index e0abd0bfb0..4d242c6126 100644 --- a/src/components/Dialog/styling/ContextMenu.scss +++ b/src/components/Dialog/styling/ContextMenu.scss @@ -41,6 +41,10 @@ .str-chat__context-menu__header { width: 100%; + color: var(--text-tertiary); + font-size: var(--typography-font-size-sm); + font-weight: var(--typography-font-weight-semi-bold, 600); + line-height: var(--typography-line-height-tight); } .str-chat__context-menu__back-button { @@ -138,7 +142,6 @@ .str-chat__context-menu__button__details { flex: 1; - text-align: right; color: var(--text-tertiary); font-weight: var(--typography-font-weight-regular); white-space: nowrap; diff --git a/src/components/Icons/styling/BaseIcon.scss b/src/components/Icons/styling/BaseIcon.scss new file mode 100644 index 0000000000..b1c14ce383 --- /dev/null +++ b/src/components/Icons/styling/BaseIcon.scss @@ -0,0 +1,3 @@ +.str-chat__icon { + flex-shrink: 0; +} \ No newline at end of file diff --git a/src/components/Icons/styling/index.scss b/src/components/Icons/styling/index.scss index da02ddb9e4..c9d3ed29c0 100644 --- a/src/components/Icons/styling/index.scss +++ b/src/components/Icons/styling/index.scss @@ -1,3 +1,4 @@ +@use 'BaseIcon'; @use 'IconArrowRotateClockwise'; @use 'IconBin'; @use 'IconCamera'; diff --git a/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx b/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx index fd1bb937ab..1b9bab8d85 100644 --- a/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx +++ b/src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx @@ -46,7 +46,11 @@ import { IconPoll, } from '../../Icons'; import { useIsCooldownActive } from '../hooks/useIsCooldownActive'; -import { CommandsSubmenu, CommandsSubmenuHeader } from './CommandsSubmenu'; +import { + CommandsMenu, + CommandsMenuClassName, + CommandsSubmenuHeader, +} from './CommandsMenu'; const AttachmentSelectorMenuInitButtonIcon = () => { const { AttachmentSelectorInitiationButtonContents } = useComponentContext(); @@ -146,7 +150,11 @@ export const DefaultAttachmentSelectorComponents = { Icon={IconCommand} onClick={() => { if (!hasSubmenu) return; - openSubmenu({ Header: submenuHeader, Submenu: submenuItems }); + openSubmenu({ + Header: submenuHeader, + menuClassName: CommandsMenuClassName, + Submenu: submenuItems, + }); }} > {t('Commands')} @@ -221,7 +229,7 @@ export const defaultAttachmentSelectorActionSet: AttachmentSelectorAction[] = [ { ActionButton: DefaultAttachmentSelectorComponents.Command, Header: CommandsSubmenuHeader, - Submenu: CommandsSubmenu, + Submenu: CommandsMenu, type: 'selectCommand', }, ]; diff --git a/src/components/MessageInput/AttachmentSelector/CommandsSubmenu.tsx b/src/components/MessageInput/AttachmentSelector/CommandsMenu.tsx similarity index 85% rename from src/components/MessageInput/AttachmentSelector/CommandsSubmenu.tsx rename to src/components/MessageInput/AttachmentSelector/CommandsMenu.tsx index fd857ae6d4..3024b7151c 100644 --- a/src/components/MessageInput/AttachmentSelector/CommandsSubmenu.tsx +++ b/src/components/MessageInput/AttachmentSelector/CommandsMenu.tsx @@ -28,11 +28,13 @@ const icons: Record = { unmute: IconVolumeFull, }; +export const CommandsMenuClassName = 'str-chat__context-menu--commands'; + export const CommandsSubmenuHeader = () => { const { t } = useTranslationContext(); const { returnToParentMenu } = useContextMenuContext(); return ( - + {t('Instant commands')} @@ -41,7 +43,16 @@ export const CommandsSubmenuHeader = () => { ); }; -export const CommandsSubmenu = () => { +export const CommandsMenuHeader = () => { + const { t } = useTranslationContext(); + return ( + + {t('Instant commands')} + + ); +}; + +export const CommandsMenu = () => { const { closeMenu } = useContextMenuContext(); const messageComposer = useMessageComposer(); const { textareaRef } = useMessageInputContext(); @@ -114,11 +125,15 @@ export const CommandContextMenuItem = ({ command: CommandResponse & { name: string }; }) => { const { args, description } = useCommandTranslation(command); + + // todo: retrieve the command trigger char from textComposer - needed adjustment in LLC + const details = useMemo(() => `/${command.name} ${args}`, [args, command.name]); + return (
    ); From 40fe6d05912292aa9412bfd0aebed69226e9c9ba Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Feb 2026 16:14:24 +0100 Subject: [PATCH 6/8] feat: migrate MessageActions components to ContextMenu use --- examples/vite/src/stream-imports-layout.scss | 2 +- examples/vite/src/stream-imports-theme.scss | 2 +- .../Dialog/styling/ContextMenu.scss | 1 - .../CustomMessageActionsList.tsx | 8 +- .../MessageActions/MessageActions.tsx | 36 +- .../MessageActions/MessageActionsBox.tsx | 321 ++++++++++++------ .../MessageActions/RemindMeSubmenu.tsx | 44 ++- .../styling/MessageActions.scss | 3 + .../MessageActions/styling/index.scss | 1 + .../MessageInput/MessageComposerActions.tsx | 4 +- .../MessageActions/MessageActions.tsx | 83 +++-- src/experimental/MessageActions/defaults.tsx | 136 +++++--- .../hooks/useSplitMessageActionSet.ts | 15 +- src/styling/index.scss | 1 + 14 files changed, 423 insertions(+), 234 deletions(-) create mode 100644 src/components/MessageActions/styling/MessageActions.scss create mode 100644 src/components/MessageActions/styling/index.scss diff --git a/examples/vite/src/stream-imports-layout.scss b/examples/vite/src/stream-imports-layout.scss index 9b13ffc4f1..d298996e9e 100644 --- a/examples/vite/src/stream-imports-layout.scss +++ b/examples/vite/src/stream-imports-layout.scss @@ -27,7 +27,7 @@ @use 'stream-chat-react/dist/scss/v2/LoadingIndicator/LoadingIndicator-layout'; @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/MessageActionsBox/MessageActionsBox-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'; diff --git a/examples/vite/src/stream-imports-theme.scss b/examples/vite/src/stream-imports-theme.scss index a41570fe1f..e3934226a3 100644 --- a/examples/vite/src/stream-imports-theme.scss +++ b/examples/vite/src/stream-imports-theme.scss @@ -24,7 +24,7 @@ @use 'stream-chat-react/dist/scss/v2/LoadingIndicator/LoadingIndicator-theme'; @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/MessageActionsBox/MessageActionsBox-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'; diff --git a/src/components/Dialog/styling/ContextMenu.scss b/src/components/Dialog/styling/ContextMenu.scss index 4d242c6126..3058491fb3 100644 --- a/src/components/Dialog/styling/ContextMenu.scss +++ b/src/components/Dialog/styling/ContextMenu.scss @@ -132,7 +132,6 @@ .str-chat__context-menu__button__label { @include utils.ellipsis-text; - max-width: 80%; flex: auto; text-align: left; color: var(--text-primary); diff --git a/src/components/MessageActions/CustomMessageActionsList.tsx b/src/components/MessageActions/CustomMessageActionsList.tsx index 969439d093..1168bf6efb 100644 --- a/src/components/MessageActions/CustomMessageActionsList.tsx +++ b/src/components/MessageActions/CustomMessageActionsList.tsx @@ -4,12 +4,13 @@ import type { LocalMessage } from 'stream-chat'; import type { CustomMessageActions } from '../../context/MessageContext'; export type CustomMessageActionsListProps = { + closeMenu?: () => void; message: LocalMessage; customMessageActions?: CustomMessageActions; }; export const CustomMessageActionsList = (props: CustomMessageActionsListProps) => { - const { customMessageActions, message } = props; + const { closeMenu, customMessageActions, message } = props; if (!customMessageActions) return null; @@ -25,7 +26,10 @@ export const CustomMessageActionsList = (props: CustomMessageActionsListProps) = aria-selected='false' className='str-chat__message-actions-list-item str-chat__message-actions-list-item-button' key={customAction} - onClick={(event) => customHandler(message, event)} + onClick={(event) => { + customHandler(message, event); + closeMenu?.(); + }} role='option' > {customAction} diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx index a1e501a9c2..43289364c0 100644 --- a/src/components/MessageActions/MessageActions.tsx +++ b/src/components/MessageActions/MessageActions.tsx @@ -31,8 +31,6 @@ export type MessageActionsProps = Partial< ActionsIcon?: React.ComponentType; /* Custom CSS class to be added to the `div` wrapping the component */ customWrapperClass?: string; - /* If true, renders the wrapper component as a `span`, not a `div` */ - inline?: boolean; /* Function that returns whether the message was sent by the connected user */ mine?: () => boolean; }; @@ -47,7 +45,6 @@ export const MessageActions = (props: MessageActionsProps) => { handleMarkUnread: propHandleMarkUnread, handleMute: propHandleMute, handlePin: propHandlePin, - inline, message: propMessage, mine, } = props; @@ -101,11 +98,20 @@ export const MessageActions = (props: MessageActionsProps) => { if (!renderMessageActions) return null; return ( - + <> + { trapFocus > { handlePin={handlePin} isUserMuted={isMuted} mine={isMine} + onClose={dialog?.close} open={dialogIsOpen} /> - - + ); }; diff --git a/src/components/MessageActions/MessageActionsBox.tsx b/src/components/MessageActions/MessageActionsBox.tsx index efdf03b5f7..c16475dc38 100644 --- a/src/components/MessageActions/MessageActionsBox.tsx +++ b/src/components/MessageActions/MessageActionsBox.tsx @@ -2,7 +2,11 @@ import clsx from 'clsx'; import type { ComponentProps } from 'react'; import React from 'react'; import { CustomMessageActionsList as DefaultCustomMessageActionsList } from './CustomMessageActionsList'; -import { RemindMeActionButton } from './RemindMeSubmenu'; +import { + RemindMeActionButton, + RemindMeSubmenu, + RemindMeSubmenuHeader, +} from './RemindMeSubmenu'; import { OPTIONAL_MESSAGE_ACTIONS, useMessageReminder } from '../Message'; import { useMessageComposer } from '../MessageInput'; import { @@ -13,6 +17,7 @@ import { } from '../../context'; import { MESSAGE_ACTIONS } from '../Message/utils'; import type { MessageContextValue } from '../../context'; +import { ContextMenu, ContextMenuButton, type ContextMenuItemComponent } from '../Dialog'; type PropsDrilledToMessageActionsBox = | 'getMessageActions' @@ -28,6 +33,7 @@ export type MessageActionsBoxProps = Pick< > & { isUserMuted: () => boolean; mine: boolean; + onClose?: () => void; open: boolean; } & ComponentProps<'div'>; @@ -42,6 +48,7 @@ const UnMemoizedMessageActionsBox = (props: MessageActionsBoxProps) => { handlePin, isUserMuted, mine, + onClose, open, ...restDivProps } = props; @@ -81,120 +88,208 @@ const UnMemoizedMessageActionsBox = (props: MessageActionsBoxProps) => { const buttonClassName = 'str-chat__message-actions-list-item str-chat__message-actions-list-item-button'; - return ( -
    -
    - - {messageActions.indexOf(MESSAGE_ACTIONS.quote) > -1 && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.pin) > -1 && !message.parent_id && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.markUnread) > -1 && - !threadList && - !!message.id && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.flag) > -1 && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.mute) > -1 && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.edit) > -1 && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.delete) > -1 && ( - - )} - {messageActions.indexOf(OPTIONAL_MESSAGE_ACTIONS.deleteForMe) > -1 && - !message.deleted_for_me && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.remindMe) > -1 && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.saveForLater) > -1 && ( - - )} -
    + const MessageActionsList = ({ + children, + className, + ...props + }: ComponentProps<'div'>) => ( +
    + {children}
    ); + + const contextMenuItems: ContextMenuItemComponent[] = []; + + if (customMessageActions) { + const CustomActionsItem: ContextMenuItemComponent = ({ closeMenu }) => ( + + ); + contextMenuItems.push(CustomActionsItem); + } + + if (messageActions.indexOf(MESSAGE_ACTIONS.quote) > -1) { + const QuoteItem: ContextMenuItemComponent = ({ closeMenu }) => ( + { + handleQuote(); + closeMenu(); + }} + > + {t('Reply')} + + ); + contextMenuItems.push(QuoteItem); + } + + if (messageActions.indexOf(MESSAGE_ACTIONS.pin) > -1 && !message.parent_id) { + const PinItem: ContextMenuItemComponent = ({ closeMenu }) => ( + { + handlePin(event); + closeMenu(); + }} + > + {!message.pinned ? t('Pin') : t('Unpin')} + + ); + contextMenuItems.push(PinItem); + } + + if ( + messageActions.indexOf(MESSAGE_ACTIONS.markUnread) > -1 && + !threadList && + !!message.id + ) { + const MarkUnreadItem: ContextMenuItemComponent = ({ closeMenu }) => ( + { + handleMarkUnread(event); + closeMenu(); + }} + > + {t('Mark as unread')} + + ); + contextMenuItems.push(MarkUnreadItem); + } + + if (messageActions.indexOf(MESSAGE_ACTIONS.flag) > -1) { + const FlagItem: ContextMenuItemComponent = ({ closeMenu }) => ( + { + handleFlag(event); + closeMenu(); + }} + > + {t('Flag')} + + ); + contextMenuItems.push(FlagItem); + } + + if (messageActions.indexOf(MESSAGE_ACTIONS.mute) > -1) { + const MuteItem: ContextMenuItemComponent = ({ closeMenu }) => ( + { + handleMute(event); + closeMenu(); + }} + > + {isUserMuted() ? t('Unmute') : t('Mute')} + + ); + contextMenuItems.push(MuteItem); + } + + if (messageActions.indexOf(MESSAGE_ACTIONS.edit) > -1) { + const EditItem: ContextMenuItemComponent = ({ closeMenu }) => ( + { + handleEdit(); + closeMenu(); + }} + > + {t('Edit Message')} + + ); + contextMenuItems.push(EditItem); + } + + if (messageActions.indexOf(MESSAGE_ACTIONS.delete) > -1) { + const DeleteItem: ContextMenuItemComponent = ({ closeMenu }) => ( + { + handleDelete(event); + closeMenu(); + }} + > + {t('Delete')} + + ); + contextMenuItems.push(DeleteItem); + } + + if ( + messageActions.indexOf(OPTIONAL_MESSAGE_ACTIONS.deleteForMe) > -1 && + !message.deleted_for_me + ) { + const DeleteForMeItem: ContextMenuItemComponent = ({ closeMenu }) => ( + { + handleDelete(event, { deleteForMe: true }); + closeMenu(); + }} + > + {t('Delete for me')} + + ); + contextMenuItems.push(DeleteForMeItem); + } + + if (messageActions.indexOf(MESSAGE_ACTIONS.remindMe) > -1) { + const RemindMeItem: ContextMenuItemComponent = ({ openSubmenu }) => ( + { + openSubmenu({ + Header: RemindMeSubmenuHeader, + Submenu: RemindMeSubmenu, + }); + }} + /> + ); + contextMenuItems.push(RemindMeItem); + } + + if (messageActions.indexOf(MESSAGE_ACTIONS.saveForLater) > -1) { + const SaveForLaterItem: ContextMenuItemComponent = ({ closeMenu }) => ( + { + if (reminder) { + client.reminders.deleteReminder(reminder.id); + } else { + client.reminders.createReminder({ messageId: message.id }); + } + closeMenu(); + }} + > + {reminder ? t('Remove reminder') : t('Save for later')} + + ); + contextMenuItems.push(SaveForLaterItem); + } + + return ( + + ); }; /** diff --git a/src/components/MessageActions/RemindMeSubmenu.tsx b/src/components/MessageActions/RemindMeSubmenu.tsx index 3058aaabfd..b7e12e8e51 100644 --- a/src/components/MessageActions/RemindMeSubmenu.tsx +++ b/src/components/MessageActions/RemindMeSubmenu.tsx @@ -1,30 +1,49 @@ import React from 'react'; import { useChatContext, useMessageContext, useTranslationContext } from '../../context'; -import { ContextMenuButton } from '../Dialog'; -import type { ComponentProps } from 'react'; +import { + ContextMenuBackButton, + ContextMenuButton, + ContextMenuHeader, + useContextMenuContext, +} from '../Dialog'; +import type { BaseContextMenuButtonProps } from '../Dialog'; +import { IconChevronRight } from '../Icons'; + +// todo: do we need to have isMine as a prop? +export type RemindMeActionButtonProps = { isMine: boolean } & BaseContextMenuButtonProps; export const RemindMeActionButton = ({ className, - isMine, -}: { isMine: boolean } & ComponentProps<'button'>) => { + isMine: _, // eslint-disable-line @typescript-eslint/no-unused-vars + ...props +}: RemindMeActionButtonProps) => { const { t } = useTranslationContext(); return ( - + {t('Remind Me')} ); }; +export const RemindMeSubmenuHeader = () => { + const { t } = useTranslationContext(); + const { returnToParentMenu } = useContextMenuContext(); + return ( + + + + {t('Remind Me')} + + + ); +}; + export const RemindMeSubmenu = () => { const { t } = useTranslationContext(); const { client } = useChatContext(); const { message } = useMessageContext(); + const { closeMenu } = useContextMenuContext(); return (
    { role='listbox' > {client.reminders.scheduledOffsetsMs.map((offsetMs) => ( - + ))} {/* todo: potential improvement to add a custom option that would trigger rendering modal with custom date picker - we need date picker */}
    diff --git a/src/components/MessageActions/styling/MessageActions.scss b/src/components/MessageActions/styling/MessageActions.scss new file mode 100644 index 0000000000..e48bf8cc98 --- /dev/null +++ b/src/components/MessageActions/styling/MessageActions.scss @@ -0,0 +1,3 @@ +.str-chat__message-actions-box { + min-width: 180px; +} \ No newline at end of file diff --git a/src/components/MessageActions/styling/index.scss b/src/components/MessageActions/styling/index.scss new file mode 100644 index 0000000000..734fc67d74 --- /dev/null +++ b/src/components/MessageActions/styling/index.scss @@ -0,0 +1 @@ +@use "MessageActions"; \ No newline at end of file diff --git a/src/components/MessageInput/MessageComposerActions.tsx b/src/components/MessageInput/MessageComposerActions.tsx index 4cad184e6b..a1450dd7ba 100644 --- a/src/components/MessageInput/MessageComposerActions.tsx +++ b/src/components/MessageInput/MessageComposerActions.tsx @@ -39,7 +39,7 @@ export const MessageComposerActions = () => { messageComposerStateSelector, ); - const { command, text } = useStateStore( + const { command } = useStateStore( messageComposer.textComposer.state, textComposerStateSelector, ); @@ -81,7 +81,7 @@ export const MessageComposerActions = () => { if (isCooldownActive) { content = ; - } else if ((compositionIsEmpty || (editedMessage && !text)) && recordingEnabled) { + } else if (compositionIsEmpty && !editedMessage && recordingEnabled) { content = ; } diff --git a/src/experimental/MessageActions/MessageActions.tsx b/src/experimental/MessageActions/MessageActions.tsx index 2ce3517cc9..0eb957a498 100644 --- a/src/experimental/MessageActions/MessageActions.tsx +++ b/src/experimental/MessageActions/MessageActions.tsx @@ -1,25 +1,40 @@ import clsx from 'clsx'; -import React, { useState } from 'react'; -import type { PropsWithChildren } from 'react'; +import React, { useMemo, useState } from 'react'; import { useChatContext, useMessageContext, useTranslationContext } from '../../context'; import { ActionsIcon } from '../../components/Message/icons'; import { + ContextMenu, + ContextMenuButton, + type ContextMenuItemComponent, + type ContextMenuItemProps, DialogAnchor, useDialogIsOpen, useDialogOnNearestManager, } from '../../components/Dialog'; -import { MessageActionsWrapper } from '../../components/MessageActions/MessageActions'; import { useBaseMessageActionSetFilter, useSplitMessageActionSet } from './hooks'; import { defaultMessageActionSet } from './defaults'; import type { MESSAGE_ACTIONS } from '../../components'; -export type MessageActionSetItem = { - Component: React.ComponentType; +type BaseMessageActionSetItem = { placement: 'quick' | 'dropdown'; type: keyof typeof MESSAGE_ACTIONS | (string & {}); }; +export type QuickMessageActionSetItem = BaseMessageActionSetItem & { + Component: React.ComponentType; + placement: 'quick'; +}; + +export type DropdownMessageActionSetItem = BaseMessageActionSetItem & { + Component: React.ComponentType; + placement: 'dropdown'; +}; + +export type MessageActionSetItem = + | QuickMessageActionSetItem + | DropdownMessageActionSetItem; + export type MessageActionsProps = { disableBaseMessageActionSetFilter?: boolean; messageActionSet?: MessageActionSetItem[]; @@ -39,7 +54,7 @@ export const MessageActions = ({ const { isMyMessage, message } = useMessageContext(); const { t } = useTranslationContext(); const [actionsBoxButtonElement, setActionsBoxButtonElement] = - useState(null); + useState(null); const filteredMessageActionSet = useBaseMessageActionSetFilter( messageActionSet, @@ -59,6 +74,17 @@ export const MessageActions = ({ dialogManager?.id, ); + const contextMenuItems = useMemo( + () => + dropdownActionSet.map(({ Component }) => { + const ActionItem: ContextMenuItemComponent = (menuProps) => ( + + ); + return ActionItem; + }), + [dropdownActionSet], + ); + // do not render anything if total action count is zero if (dropdownActionSet.length + quickActionSet.length === 0) { return null; @@ -72,17 +98,20 @@ export const MessageActions = ({ })} > {dropdownActionSet.length > 0 && ( - - +
    - - {dropdownActionSet.map(({ Component: DropdownActionComponent, type }) => ( - - ))} - + - + )} {quickActionSet.map(({ Component: QuickActionComponent, type }) => ( @@ -106,22 +140,3 @@ export const MessageActions = ({
    ); }; - -const DropdownBox = ({ children, open }: PropsWithChildren<{ open: boolean }>) => { - const { t } = useTranslationContext(); - return ( -
    -
    - {children} -
    -
    - ); -}; diff --git a/src/experimental/MessageActions/defaults.tsx b/src/experimental/MessageActions/defaults.tsx index 3b833f4ccf..575365e65c 100644 --- a/src/experimental/MessageActions/defaults.tsx +++ b/src/experimental/MessageActions/defaults.tsx @@ -1,5 +1,4 @@ /* eslint-disable sort-keys */ -import type { ComponentPropsWithoutRef } from 'react'; import React from 'react'; import { isUserMuted, useMessageComposer, useMessageReminder } from '../../components'; @@ -9,27 +8,21 @@ import { } from '../../components/Message/icons'; import { ReactionSelectorWithButton } from '../../components/Reactions/ReactionSelectorWithButton'; import { useChatContext, useMessageContext, useTranslationContext } from '../../context'; -import { RemindMeActionButton } from '../../components/MessageActions/RemindMeSubmenu'; +import { + RemindMeActionButton, + RemindMeSubmenu, + RemindMeSubmenuHeader, +} from '../../components/MessageActions/RemindMeSubmenu'; +import { ContextMenuButton } from '../../components/Dialog'; +import type { ContextMenuItemProps } from '../../components/Dialog'; import type { MessageActionSetItem } from './MessageActions'; const msgActionsBoxButtonClassName = 'str-chat__message-actions-list-item-button' as const; -export const DefaultDropdownActionButton = ({ - 'aria-selected': ariaSelected = 'false', - children, - className = msgActionsBoxButtonClassName, - role = 'option', - ...rest -}: ComponentPropsWithoutRef<'button'>) => ( - -); - const DefaultMessageActionComponents = { dropdown: { - Quote() { + Quote({ closeMenu }: ContextMenuItemProps) { const { message } = useMessageContext(); const { t } = useTranslationContext(); const messageComposer = useMessageComposer(); @@ -48,100 +41,153 @@ const DefaultMessageActionComponents = { }; return ( - + { + handleQuote(); + closeMenu(); + }} + > {t('Quote')} - +
    ); }, - Pin() { + Pin({ closeMenu }: ContextMenuItemProps) { const { handlePin, message } = useMessageContext(); const { t } = useTranslationContext(); return ( - + { + handlePin(event); + closeMenu(); + }} + > {!message.pinned ? t('Pin') : t('Unpin')} - +
    ); }, - MarkUnread() { + MarkUnread({ closeMenu }: ContextMenuItemProps) { const { handleMarkUnread } = useMessageContext(); const { t } = useTranslationContext(); return ( - + { + handleMarkUnread(event); + closeMenu(); + }} + > {t('Mark as unread')} - + ); }, - Flag() { + Flag({ closeMenu }: ContextMenuItemProps) { const { handleFlag } = useMessageContext(); const { t } = useTranslationContext(); return ( - + { + handleFlag(event); + closeMenu(); + }} + > {t('Flag')} - + ); }, - Mute() { + Mute({ closeMenu }: ContextMenuItemProps) { const { handleMute, message } = useMessageContext(); const { mutes } = useChatContext(); const { t } = useTranslationContext(); return ( - + { + handleMute(event); + closeMenu(); + }} + > {isUserMuted(message, mutes) ? t('Unmute') : t('Mute')} - + ); }, - Edit() { + Edit({ closeMenu }: ContextMenuItemProps) { const messageComposer = useMessageComposer(); const { message } = useMessageContext(); const { t } = useTranslationContext(); return ( - messageComposer.initState({ composition: message })} + { + messageComposer.initState({ composition: message }); + closeMenu(); + }} > {t('Edit Message')} - + ); }, - Delete() { + Delete({ closeMenu }: ContextMenuItemProps) { const { handleDelete } = useMessageContext(); const { t } = useTranslationContext(); return ( - + { + handleDelete(event); + closeMenu(); + }} + > {t('Delete')} - + ); }, - RemindMe() { + RemindMe({ openSubmenu }: ContextMenuItemProps) { const { isMyMessage } = useMessageContext(); return ( { + openSubmenu({ + Header: RemindMeSubmenuHeader, + Submenu: RemindMeSubmenu, + }); + }} /> ); }, - SaveForLater() { + SaveForLater({ closeMenu }: ContextMenuItemProps) { const { client } = useChatContext(); const { message } = useMessageContext(); const { t } = useTranslationContext(); const reminder = useMessageReminder(message.id); return ( - - reminder - ? client.reminders.deleteReminder(reminder.id) - : client.reminders.createReminder({ messageId: message.id }) - } + { + if (reminder) { + client.reminders.deleteReminder(reminder.id); + } else { + client.reminders.createReminder({ messageId: message.id }); + } + closeMenu(); + }} > {reminder ? t('Remove reminder') : t('Save for later')} - + ); }, }, diff --git a/src/experimental/MessageActions/hooks/useSplitMessageActionSet.ts b/src/experimental/MessageActions/hooks/useSplitMessageActionSet.ts index d824c5038b..cfbc87821c 100644 --- a/src/experimental/MessageActions/hooks/useSplitMessageActionSet.ts +++ b/src/experimental/MessageActions/hooks/useSplitMessageActionSet.ts @@ -1,11 +1,18 @@ import { useMemo } from 'react'; -import type { MessageActionSetItem } from '../MessageActions'; +import type { + DropdownMessageActionSetItem, + MessageActionSetItem, + QuickMessageActionSetItem, +} from '../MessageActions'; export const useSplitMessageActionSet = (messageActionSet: MessageActionSetItem[]) => - useMemo(() => { - const quickActionSet: MessageActionSetItem[] = []; - const dropdownActionSet: MessageActionSetItem[] = []; + useMemo<{ + dropdownActionSet: DropdownMessageActionSetItem[]; + quickActionSet: QuickMessageActionSetItem[]; + }>(() => { + const quickActionSet: QuickMessageActionSetItem[] = []; + const dropdownActionSet: DropdownMessageActionSetItem[] = []; for (const action of messageActionSet) { if (action.placement === 'quick') quickActionSet.push(action); diff --git a/src/styling/index.scss b/src/styling/index.scss index 7d90d3136b..3740fea36a 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -24,6 +24,7 @@ @use "../components/Avatar/styling/Avatar" as Avatar; @use "../components/MediaRecorder/AudioRecorder/styling" as AudioRecorder; @use "../components/Message/styling" as Message; +@use "../components/MessageActions/styling" as MessageActions; @use "../components/MessageInput/styling" as MessageComposer; @use "../components/Reactions/styling/ReactionSelector" as ReactionSelector; @use "../components/TextareaComposer/styling/" as TextareaComposer; From e8bcb55fe594db45d375a5d12f9e348fd2abb7bb Mon Sep 17 00:00:00 2001 From: MartinCupela <32706194+MartinCupela@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:17:13 +0100 Subject: [PATCH 7/8] refactor: streamline useMemo Co-authored-by: Anton Arnautov <43254280+arnautov-anton@users.noreply.github.com> --- .../MessageActions/hooks/useSplitMessageActionSet.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/MessageActions/hooks/useSplitMessageActionSet.ts b/src/components/MessageActions/hooks/useSplitMessageActionSet.ts index cfbc87821c..48e7d6258f 100644 --- a/src/components/MessageActions/hooks/useSplitMessageActionSet.ts +++ b/src/components/MessageActions/hooks/useSplitMessageActionSet.ts @@ -19,5 +19,14 @@ export const useSplitMessageActionSet = (messageActionSet: MessageActionSetItem[ if (action.placement === 'dropdown') dropdownActionSet.push(action); } - return { dropdownActionSet, quickActionSet }; + useMemo(() => { + const quickActionSet: QuickMessageActionSetItem[] = []; + const dropdownActionSet: DropdownMessageActionSetItem[] = []; + +for (const action of messageActionSet) { +if (action.placement === 'quick') quickActionSet.push(action); +if (action.placement === 'dropdown') dropdownActionSet.push(action); +} + +return { dropdownActionSet, quickActionSet } as const; }, [messageActionSet]); From 073751a7537ac49b92dcdf7d81f0aa6701e578b1 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Feb 2026 17:19:16 +0100 Subject: [PATCH 8/8] fix: post merge fixes --- .../hooks/useSplitMessageActionSet.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/components/MessageActions/hooks/useSplitMessageActionSet.ts b/src/components/MessageActions/hooks/useSplitMessageActionSet.ts index 48e7d6258f..17f3ecfab9 100644 --- a/src/components/MessageActions/hooks/useSplitMessageActionSet.ts +++ b/src/components/MessageActions/hooks/useSplitMessageActionSet.ts @@ -7,10 +7,7 @@ import type { } from '../MessageActions'; export const useSplitMessageActionSet = (messageActionSet: MessageActionSetItem[]) => - useMemo<{ - dropdownActionSet: DropdownMessageActionSetItem[]; - quickActionSet: QuickMessageActionSetItem[]; - }>(() => { + useMemo(() => { const quickActionSet: QuickMessageActionSetItem[] = []; const dropdownActionSet: DropdownMessageActionSetItem[] = []; @@ -19,14 +16,5 @@ export const useSplitMessageActionSet = (messageActionSet: MessageActionSetItem[ if (action.placement === 'dropdown') dropdownActionSet.push(action); } - useMemo(() => { - const quickActionSet: QuickMessageActionSetItem[] = []; - const dropdownActionSet: DropdownMessageActionSetItem[] = []; - -for (const action of messageActionSet) { -if (action.placement === 'quick') quickActionSet.push(action); -if (action.placement === 'dropdown') dropdownActionSet.push(action); -} - -return { dropdownActionSet, quickActionSet } as const; + return { dropdownActionSet, quickActionSet } as const; }, [messageActionSet]);