fix: Use i18nTitle provided by the app in `ComposerBoxPopupPreview` component (#35079)

Co-authored-by: Tasso Evangelista <2263066+tassoevan@users.noreply.github.com>
pull/35135/head^2
Tiago Evangelista Pinto 1 year ago committed by GitHub
parent ae763a64ae
commit 5f9adcd744
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/cuddly-garlics-cover.md
  2. 99
      apps/meteor/client/views/room/composer/ComposerBoxPopupPreview.tsx
  3. 144
      apps/meteor/client/views/room/composer/hooks/useComposerBoxPopup.ts
  4. 4
      apps/meteor/client/views/room/composer/hooks/useMessageComposerMergedRefs.ts
  5. 53
      apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx
  6. 4
      apps/meteor/client/views/room/contexts/ComposerPopupContext.ts
  7. 23
      apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx

@ -0,0 +1,5 @@
---
"@rocket.chat/meteor": patch
---
Fixes unused `i18nTitle` provided by the app in message composer popup previewer

@ -1,6 +1,6 @@
import { Box, Skeleton, Tile, Option } from '@rocket.chat/fuselage';
import { useMethod } from '@rocket.chat/ui-contexts';
import type { ForwardedRef } from 'react';
import type { ForwardedRef, ReactNode } from 'react';
import { forwardRef, useEffect, useId, useImperativeHandle } from 'react';
import type { ComposerBoxPopupProps } from './ComposerBoxPopup';
@ -9,13 +9,14 @@ import { useChat } from '../contexts/ChatContext';
type ComposerBoxPopupPreviewItem = { _id: string; type: 'image' | 'video' | 'audio' | 'text' | 'other'; value: string; sort?: number };
type ComposerBoxPopupPreviewProps = ComposerBoxPopupProps<ComposerBoxPopupPreviewItem> & {
title?: ReactNode;
rid: string;
tmid?: string;
suspended?: boolean;
suspended: boolean;
};
const ComposerBoxPopupPreview = forwardRef(function ComposerBoxPopupPreview(
{ focused, items, rid, tmid, select, suspended }: ComposerBoxPopupPreviewProps,
{ focused, items, title, rid, tmid, select, suspended }: ComposerBoxPopupPreviewProps,
ref: ForwardedRef<
| {
getFilter?: () => unknown;
@ -27,6 +28,7 @@ const ComposerBoxPopupPreview = forwardRef(function ComposerBoxPopupPreview(
const id = useId();
const chat = useChat();
const executeSlashCommandPreviewMethod = useMethod('executeSlashCommandPreview');
useImperativeHandle(
ref,
() => ({
@ -96,48 +98,55 @@ const ComposerBoxPopupPreview = forwardRef(function ComposerBoxPopupPreview(
return (
<Box position='relative'>
<Tile display='flex' padding={8} role='menu' mbe={8} aria-labelledby={id}>
<Box role='listbox' display='flex' overflow='auto' fontSize={0} width={0} flexGrow={1} aria-busy={isLoading}>
{isLoading &&
Array(5)
.fill(5)
.map((_, index) => <Skeleton variant='rect' h='100px' w='120px' m={2} key={index} />)}
{!isLoading &&
itemsFlat.map((item) => (
<Box
onClick={() => select(item)}
role='option'
className={['popup-item', item === focused && 'selected'].filter(Boolean).join(' ')}
id={`popup-item-${item._id}`}
key={item._id}
bg={item === focused ? 'selected' : undefined}
borderColor={item === focused ? 'highlight' : 'transparent'}
tabIndex={item === focused ? 0 : -1}
aria-selected={item === focused}
m={2}
borderWidth='default'
borderRadius='x4'
>
{item.type === 'image' && <img src={item.value} alt={item._id} />}
{item.type === 'audio' && (
<audio controls>
<track kind='captions' />
<source src={item.value} />
Your browser does not support the audio element.
</audio>
)}
{item.type === 'video' && (
<video controls className='inline-video'>
<track kind='captions' />
<source src={item.value} />
Your browser does not support the video element.
</video>
)}
{item.type === 'text' && <Option>{item.value}</Option>}
{item.type === 'other' && <code>{item.value}</code>}
</Box>
))}
<Tile padding={0} role='menu' mbe={8} overflow='hidden' aria-labelledby={id}>
{title && (
<Box bg='tint' pi={16} pb={8} id={id}>
{title}
</Box>
)}
<Box display='flex' padding={8}>
<Box role='listbox' display='flex' overflow='auto' fontSize={0} width={0} flexGrow={1} aria-busy={isLoading}>
{isLoading &&
Array(5)
.fill(5)
.map((_, index) => <Skeleton variant='rect' h='100px' w='120px' m={2} key={index} />)}
{!isLoading &&
itemsFlat.map((item) => (
<Box
onClick={() => select(item)}
role='option'
className={['popup-item', item === focused && 'selected'].filter(Boolean).join(' ')}
id={`popup-item-${item._id}`}
key={item._id}
bg={item === focused ? 'selected' : undefined}
borderColor={item === focused ? 'highlight' : 'transparent'}
tabIndex={item === focused ? 0 : -1}
aria-selected={item === focused}
m={2}
borderWidth='default'
borderRadius='x4'
>
{item.type === 'image' && <img src={item.value} alt={item._id} />}
{item.type === 'audio' && (
<audio controls>
<track kind='captions' />
<source src={item.value} />
Your browser does not support the audio element.
</audio>
)}
{item.type === 'video' && (
<video controls className='inline-video'>
<track kind='captions' />
<source src={item.value} />
Your browser does not support the video element.
</video>
)}
{item.type === 'text' && <Option>{item.value}</Option>}
{item.type === 'other' && <code>{item.value}</code>}
</Box>
))}
</Box>
</Box>
</Tile>
</Box>

@ -9,7 +9,7 @@ import type { ComposerPopupOption } from '../../contexts/ComposerPopupContext';
type ComposerBoxPopupImperativeCommands<T> = MutableRefObject<
| {
getFilter?: () => unknown;
getFilter?: () => string;
select?: (s: T) => void;
}
| undefined
@ -19,66 +19,60 @@ type ComposerBoxPopupOptions<T extends { _id: string; sort?: number | undefined
type ComposerBoxPopupResult<T extends { _id: string; sort?: number }> =
| {
popup: ComposerPopupOption<T>;
option: ComposerPopupOption<T>;
items: UseQueryResult<T[]>[];
focused: T | undefined;
ariaActiveDescendant: string | undefined;
select: (item: T) => void;
callbackRef: (node: HTMLElement) => void;
commandsRef: ComposerBoxPopupImperativeCommands<T>;
suspended: boolean;
filter: unknown;
clearPopup: () => void;
clear: () => void;
}
| {
popup: undefined;
option: undefined;
items: undefined;
focused: undefined;
ariaActiveDescendant: undefined;
callbackRef: (node: HTMLElement) => void;
select: undefined;
commandsRef: ComposerBoxPopupImperativeCommands<T>;
suspended: boolean;
suspended: undefined;
filter: unknown;
clearPopup: () => void;
clear: () => void;
};
const keys = {
TAB: 9,
ENTER: 13,
ESC: 27,
ARROW_LEFT: 37,
ARROW_UP: 38,
ARROW_RIGHT: 39,
ARROW_DOWN: 40,
};
} as const;
export const useComposerBoxPopup = <T extends { _id: string; sort?: number }>({
configurations,
}: {
configurations: ComposerBoxPopupOptions<T>[];
}): ComposerBoxPopupResult<T> => {
const [popup, setPopup] = useState<ComposerBoxPopupOptions<T> | undefined>(undefined);
export const useComposerBoxPopup = <T extends { _id: string; sort?: number }>(
options: ComposerBoxPopupOptions<T>[],
): ComposerBoxPopupResult<T> => {
const [optionIndex, setOptionIndex] = useState<number>(-1);
const [focused, setFocused] = useState<T | undefined>(undefined);
const [filter, setFilter] = useState<unknown>('');
const [filter, setFilter] = useState('');
const option = options[optionIndex];
const commandsRef: ComposerBoxPopupImperativeCommands<T> = useRef();
const { queries: items, suspended } = useComposerBoxPopupQueries(filter, popup) as {
const { queries: items, suspended } = useComposerBoxPopupQueries(filter, option) as {
queries: UseQueryResult<T[]>[];
suspended: boolean;
};
const chat = useChat();
const ariaActiveDescendant = focused ? `popup-item-${focused._id}` : undefined;
useEffect(() => {
if (!popup) {
if (!option) {
return;
}
if (popup?.preview && suspended) {
if (option?.preview && suspended) {
setFocused(undefined);
return;
}
@ -89,10 +83,10 @@ export const useComposerBoxPopup = <T extends { _id: string; sort?: number }>({
.sort((a, b) => (('sort' in a && a.sort) || 0) - (('sort' in b && b.sort) || 0));
return sortedItems.find((item) => item._id === focused?._id) ?? sortedItems[0];
});
}, [items, popup, suspended]);
}, [items, option, suspended]);
const select = useEffectEvent((item: T) => {
if (!popup) {
if (!option) {
throw new Error('No popup is open');
}
@ -101,33 +95,33 @@ export const useComposerBoxPopup = <T extends { _id: string; sort?: number }>({
} else {
const value = chat?.composer?.substring(0, chat?.composer?.selection.start);
const selector =
popup.matchSelectorRegex ??
(popup.triggerAnywhere ? new RegExp(`(?:^| |\n)(${popup.trigger})([^\\s]*$)`) : new RegExp(`(?:^)(${popup.trigger})([^\\s]*$)`));
option.matchSelectorRegex ??
(option.triggerAnywhere ? new RegExp(`(?:^| |\n)(${option.trigger})([^\\s]*$)`) : new RegExp(`(?:^)(${option.trigger})([^\\s]*$)`));
const result = value?.match(selector);
if (!result || !value) {
return;
}
chat?.composer?.replaceText((popup.prefix ?? popup.trigger ?? '') + popup.getValue(item) + (popup.suffix ?? ''), {
chat?.composer?.replaceText((option.prefix ?? option.trigger ?? '') + option.getValue(item) + (option.suffix ?? ''), {
start: value.lastIndexOf(result[1] + result[2]),
end: chat?.composer?.selection.start,
});
}
setPopup(undefined);
setOptionIndex(-1);
setFocused(undefined);
});
const setConfigByInput = useEffectEvent((): ComposerBoxPopupOptions<T> | undefined => {
const setOptionByInput = useEffectEvent((): ComposerBoxPopupOptions<T> | undefined => {
const value = chat?.composer?.substring(0, chat?.composer?.selection.start);
if (!value) {
setPopup(undefined);
setOptionIndex(-1);
setFocused(undefined);
return;
}
const configuration = configurations.find(({ trigger, matchSelectorRegex, triggerAnywhere, triggerLength }) => {
const optionIndex = options.findIndex(({ trigger, matchSelectorRegex, triggerAnywhere, triggerLength }) => {
const selector =
matchSelectorRegex ?? (triggerAnywhere ? new RegExp(`(?:^| |\n)(${trigger})[^\\s]*$`) : new RegExp(`(?:^)(${trigger})[^\\s]*$`));
const result = selector.test(value);
@ -137,50 +131,49 @@ export const useComposerBoxPopup = <T extends { _id: string; sort?: number }>({
const filter = value.match(selector);
return filter && triggerLength < filter[0].length;
});
setPopup(configuration);
if (!configuration) {
setOptionIndex(optionIndex);
const option = options[optionIndex];
if (!option) {
setFocused(undefined);
setFilter('');
}
if (configuration) {
if (option) {
const selector =
configuration.matchSelectorRegex ??
(configuration.triggerAnywhere
? new RegExp(`(?:^| |\n)(${configuration.trigger})([^\\s]*$)`)
: new RegExp(`(?:^)(${configuration.trigger})([^\\s]*$)`));
option.matchSelectorRegex ??
(option.triggerAnywhere ? new RegExp(`(?:^| |\n)(${option.trigger})([^\\s]*$)`) : new RegExp(`(?:^)(${option.trigger})([^\\s]*$)`));
const result = value.match(selector);
setFilter(commandsRef.current?.getFilter?.() ?? (result ? result[2] : ''));
}
return configuration;
return option;
});
const onFocus = useEffectEvent(() => {
if (popup) {
const handleFocus = useEffectEvent(() => {
if (option) {
return;
}
setConfigByInput();
setOptionByInput();
});
const keyup = useEffectEvent((event: KeyboardEvent) => {
if (!setConfigByInput()) {
const handleKeyUp = useEffectEvent((event: KeyboardEvent) => {
if (!setOptionByInput()) {
return;
}
if (!popup) {
if (!option) {
return;
}
if (popup.closeOnEsc === true && event.which === keys.ESC) {
setPopup(undefined);
if (option.closeOnEsc === true && event.which === keys.ESC) {
setOptionIndex(-1);
setFocused(undefined);
event.preventDefault();
event.stopImmediatePropagation();
}
});
const keydown = useEffectEvent((event: KeyboardEvent) => {
if (!popup) {
const handleKeyDown = useEffectEvent((event: KeyboardEvent) => {
if (!option) {
return;
}
@ -235,54 +228,59 @@ export const useComposerBoxPopup = <T extends { _id: string; sort?: number }>({
}
});
const clearPopup = useEffectEvent(() => {
if (!popup) {
const clear = useEffectEvent(() => {
if (!option) {
return;
}
setPopup(undefined);
setOptionIndex(-1);
setFocused(undefined);
setFilter('');
});
const ref = useRef<HTMLElement | null>(null);
const callbackRef = useCallback(
(node: HTMLElement | null) => {
if (!node) {
return;
if (ref.current) {
ref.current.removeEventListener('keyup', handleKeyUp);
ref.current.removeEventListener('keydown', handleKeyDown);
ref.current.removeEventListener('focus', handleFocus);
ref.current = null;
}
node.addEventListener('keyup', keyup);
node.addEventListener('keydown', keydown);
node.addEventListener('focus', onFocus);
if (node) {
ref.current = node;
node.addEventListener('keyup', handleKeyUp);
node.addEventListener('keydown', handleKeyDown);
node.addEventListener('focus', handleFocus);
}
},
[keyup, keydown, onFocus],
[handleKeyUp, handleKeyDown, handleFocus],
);
if (!popup) {
if (!option) {
return {
callbackRef,
focused: undefined,
option: undefined,
items: undefined,
ariaActiveDescendant: undefined,
popup: undefined,
focused: undefined,
select: undefined,
suspended: true,
callbackRef,
commandsRef,
suspended: undefined,
filter: undefined,
clearPopup,
clear,
};
}
return {
focused,
option,
items,
ariaActiveDescendant,
popup,
focused,
select,
filter,
suspended,
commandsRef,
callbackRef,
clearPopup,
commandsRef,
suspended,
filter,
clear,
};
};

@ -11,9 +11,9 @@ const isMutableRefObject = <T>(x: unknown): x is MutableRefObject<T> => typeof x
* @param refs The refs to merge.
* @returns The merged ref callback.
*/
export const useMessageComposerMergedRefs = <T>(...refs: Ref<T>[]): RefCallback<T> => {
export const useMessageComposerMergedRefs = <T>(...refs: (Ref<T> | undefined)[]): RefCallback<T> => {
return useCallback((refValue: T) => {
refs.filter(Boolean).forEach((ref) => {
refs.forEach((ref) => {
if (isRefCallback<T>(ref)) {
ref(refValue);
return;

@ -32,7 +32,7 @@ import { keyCodes } from '../../../../lib/utils/keyCodes';
import AudioMessageRecorder from '../../../composer/AudioMessageRecorder';
import VideoMessageRecorder from '../../../composer/VideoMessageRecorder';
import { useChat } from '../../contexts/ChatContext';
import { useComposerPopup } from '../../contexts/ComposerPopupContext';
import { useComposerPopupOptions } from '../../contexts/ComposerPopupContext';
import { useRoom } from '../../contexts/RoomContext';
import ComposerBoxPopup from '../ComposerBoxPopup';
import ComposerBoxPopupPreview from '../ComposerBoxPopupPreview';
@ -161,7 +161,7 @@ const MessageBox = ({
const handleSendMessage = useEffectEvent(() => {
const text = chat.composer?.text ?? '';
chat.composer?.clear();
clearPopup();
popup.clear();
onSend?.({
value: text,
@ -327,22 +327,8 @@ const MessageBox = ({
}
});
const composerPopupConfig = useComposerPopup();
const {
popup,
focused,
items,
ariaActiveDescendant,
suspended,
select,
commandsRef,
callbackRef: c,
filter,
clearPopup,
} = useComposerBoxPopup<{ _id: string; sort?: number }>({
configurations: composerPopupConfig,
});
const popupOptions = useComposerPopupOptions();
const popup = useComposerBoxPopup(popupOptions);
const keyDownHandlerCallbackRef = useCallback(
(node: HTMLTextAreaElement) => {
@ -356,15 +342,21 @@ const MessageBox = ({
[handler],
);
const mergedRefs = useMessageComposerMergedRefs(c, textareaRef, callbackRef, autofocusRef, keyDownHandlerCallbackRef);
const mergedRefs = useMessageComposerMergedRefs(popup.callbackRef, textareaRef, callbackRef, autofocusRef, keyDownHandlerCallbackRef);
const shouldPopupPreview = useEnablePopupPreview(filter, popup);
const shouldPopupPreview = useEnablePopupPreview(popup.filter, popup.option);
return (
<>
{chat.composer?.quotedMessages && <MessageBoxReplies />}
{shouldPopupPreview && popup && (
<ComposerBoxPopup select={select} items={items} focused={focused} title={popup.title} renderItem={popup.renderItem} />
{shouldPopupPreview && popup.option && (
<ComposerBoxPopup
select={popup.select}
items={popup.items}
focused={popup.focused}
title={popup.option.title}
renderItem={popup.option.renderItem}
/>
)}
{/*
SlashCommand Preview popup works in a weird way
@ -372,16 +364,17 @@ const MessageBox = ({
After that we need to the slashcommand list and check if the command exists and provide the preview
if not the query is `suspend` which means the slashcommand is not found or doesn't have a preview
*/}
{popup?.preview && (
{popup.option?.preview && (
<ComposerBoxPopupPreview
select={select}
items={items as any}
focused={focused as any}
renderItem={popup.renderItem}
ref={commandsRef}
select={popup.select}
items={popup.items as any}
focused={popup.focused as any}
title={popup.option.title}
renderItem={popup.option.renderItem}
ref={popup.commandsRef}
rid={room._id}
tmid={tmid}
suspended={suspended}
suspended={popup.suspended}
/>
)}
<MessageBoxHint
@ -402,7 +395,7 @@ const MessageBox = ({
style={textAreaStyle}
placeholder={composerPlaceholder}
onPaste={handlePaste}
aria-activedescendant={ariaActiveDescendant}
aria-activedescendant={popup.focused ? `popup-item-${popup.focused._id}` : undefined}
/>
<div ref={shadowRef} style={shadowStyle} />
<MessageComposerToolbar>

@ -42,10 +42,10 @@ export const createMessageBoxPopupConfig = <T extends { _id: string; sort?: numb
};
};
export const useComposerPopup = () => {
export const useComposerPopupOptions = () => {
const composerPopupContext = useContext(ComposerPopupContext);
if (!composerPopupContext) {
throw new Error('useComposerPopup must be used within ComposerPopupContext');
throw new Error('useComposerPopupOptions must be used within ComposerPopupContext');
}
return composerPopupContext;
};

@ -3,7 +3,7 @@ import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { useMethod, useSetting, useUserPreference } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
@ -36,6 +36,7 @@ const ComposerPopupProvider = ({ children, room }: ComposerPopupProviderProps) =
const suggestionsCount = useSetting('Number_of_users_autocomplete_suggestions', 5);
const cannedResponseEnabled = useSetting('Canned_Responses_Enable', true);
const [recentEmojis] = useLocalStorage('emoji.recent', []);
const [previewTitle, setPreviewTitle] = useState('');
const isOmnichannel = isOmnichannelRoom(room);
const useEmoji = useUserPreference('useEmojis');
const { t, i18n } = useTranslation();
@ -357,10 +358,14 @@ const ComposerPopupProvider = ({ children, room }: ComposerPopupProviderProps) =
},
}),
createMessageBoxPopupConfig({
title: previewTitle,
matchSelectorRegex: /(?:^)(\/[\w\d\S]+ )[^]*$/,
preview: true,
getItemsFromLocal: async ({ cmd, params, tmid }: { cmd: string; params: string; tmid: string }) => {
const result = await call({ cmd, params, msg: { rid, tmid } });
setPreviewTitle(t(result?.i18nTitle ?? ''));
return (
result?.items.map((item) => ({
_id: item.id,
@ -371,7 +376,21 @@ const ComposerPopupProvider = ({ children, room }: ComposerPopupProviderProps) =
},
}),
].filter(Boolean);
}, [t, i18n, cannedResponseEnabled, isOmnichannel, recentEmojis, suggestionsCount, userSpotlight, rid, call, useEmoji, encrypted]);
}, [
t,
useEmoji,
encrypted,
cannedResponseEnabled,
isOmnichannel,
previewTitle,
suggestionsCount,
userSpotlight,
rid,
recentEmojis,
i18n,
call,
setPreviewTitle,
]);
return <ComposerPopupContext.Provider value={value} children={children} />;
};

Loading…
Cancel
Save