Skip to content
Snippets Groups Projects
Unverified Commit 47e7a380 authored by Douglas Fabris's avatar Douglas Fabris Committed by GitHub
Browse files

feat: Quick reactions on message toolbox (#29569)

parent 60a7b5cf
No related branches found
No related tags found
No related merge requests found
---
"@rocket.chat/meteor": minor
---
feat: Quick reactions on message toolbox
......@@ -57,7 +57,7 @@ export const createEmojiList = (
if (!image) {
continue;
}
emojiList.push({ emoji: current, image });
emojiList.push({ emoji: current, image, emojiHandle: emojiToRender });
}
});
......
......@@ -3,6 +3,7 @@ import type { TranslationKey } from '@rocket.chat/ui-contexts';
export type EmojiItem = {
emoji: string;
image: string;
emojiHandle: string;
};
export type EmojiCategory = {
......
......@@ -8,6 +8,9 @@ import React, { memo, useMemo } from 'react';
import type { MessageActionContext } from '../../../../app/ui-utils/client/lib/MessageAction';
import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction';
import { sdk } from '../../../../app/utils/client/lib/SDKClient';
import { useEmojiPickerData } from '../../../contexts/EmojiPickerContext';
import EmojiElement from '../../../views/composer/EmojiPicker/EmojiElement';
import { useIsSelecting } from '../../../views/room/MessageList/contexts/SelectedMessagesContext';
import { useAutoTranslate } from '../../../views/room/MessageList/hooks/useAutoTranslate';
import { useChat } from '../../../views/room/contexts/ChatContext';
......@@ -52,6 +55,7 @@ const Toolbox = ({ message, messageContext, room, subscription }: ToolboxProps):
const mapSettings = useMemo(() => Object.fromEntries(settings.map((setting) => [setting._id, setting.value])), [settings]);
const chat = useChat();
const { addRecentEmoji, emojiListByCategory } = useEmojiPickerData();
const actionsQueryResult = useQuery(['rooms', room._id, 'messages', message._id, 'actions'] as const, async () => {
const messageActions = await MessageAction.getButtons(
......@@ -78,8 +82,29 @@ const Toolbox = ({ message, messageContext, room, subscription }: ToolboxProps):
return null;
}
const handleSetReaction = (emoji: string) => {
sdk.call('setReaction', `:${emoji}:`, message._id);
addRecentEmoji(emoji);
};
const recentList = emojiListByCategory.filter(({ key }) => key === 'recent')[0].emojis.list;
return (
<MessageToolbox>
{recentList.length &&
recentList.slice(0, 3).map(({ emoji, image, emojiHandle }) => {
return (
<EmojiElement
small
key={emoji}
title={emoji}
emoji={emoji}
image={image}
emojiHandle={emojiHandle}
onClick={() => handleSetReaction(emoji)}
/>
);
})}
{actionsQueryResult.data?.message.map((action) => (
<MessageToolboxItem
onClick={(e): void => action.action(e, { message, tabbar: toolbox, room, chat, autoTranslateOptions })}
......
import { createContext, useContext } from 'react';
import type { EmojiByCategory } from '../../app/emoji/client';
type EmojiPickerContextValue = {
open: (ref: Element, callback: (emoji: string) => void) => void;
isOpen: boolean;
......@@ -7,6 +9,16 @@ type EmojiPickerContextValue = {
emojiToPreview: { emoji: string; name: string } | null;
handlePreview: (emoji: string, name: string) => void;
handleRemovePreview: () => void;
addRecentEmoji: (emoji: string) => void;
emojiListByCategory: EmojiByCategory[];
recentEmojis: string[];
setRecentEmojis: (emoji: string[]) => void;
actualTone: number;
currentCategory: string;
setCurrentCategory: (category: string) => void;
customItemsLimit: number;
setCustomItemsLimit: (limit: number) => void;
setActualTone: (tone: number) => void;
};
export const EmojiPickerContext = createContext<EmojiPickerContextValue | undefined>(undefined);
......@@ -30,3 +42,16 @@ export const usePreviewEmoji = () => ({
handlePreview: useEmojiPickerContext().handlePreview,
handleRemovePreview: useEmojiPickerContext().handleRemovePreview,
});
export const useEmojiPickerData = () => ({
addRecentEmoji: useEmojiPickerContext().addRecentEmoji,
emojiListByCategory: useEmojiPickerContext().emojiListByCategory,
recentEmojis: useEmojiPickerContext().recentEmojis,
setRecentEmojis: useEmojiPickerContext().setRecentEmojis,
actualTone: useEmojiPickerContext().actualTone,
currentCategory: useEmojiPickerContext().currentCategory,
setCurrentCategory: useEmojiPickerContext().setCurrentCategory,
customItemsLimit: useEmojiPickerContext().customItemsLimit,
setCustomItemsLimit: useEmojiPickerContext().setCustomItemsLimit,
setActualTone: useEmojiPickerContext().setActualTone,
});
import { useDebouncedState } from '@rocket.chat/fuselage-hooks';
import { useDebouncedState, useLocalStorage } from '@rocket.chat/fuselage-hooks';
import type { ReactNode, ReactElement } from 'react';
import React, { useState, useCallback, useMemo } from 'react';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import type { EmojiByCategory } from '../../app/emoji/client';
import { emoji, updateRecent, createEmojiList, createPickerEmojis, CUSTOM_CATEGORY } from '../../app/emoji/client';
import { EmojiPickerContext } from '../contexts/EmojiPickerContext';
import EmojiPicker from '../views/composer/EmojiPicker/EmojiPicker';
const DEFAULT_ITEMS_LIMIT = 90;
const EmojiPickerProvider = ({ children }: { children: ReactNode }): ReactElement => {
const [emojiPicker, setEmojiPicker] = useState<ReactElement | null>(null);
const [emojiToPreview, setEmojiToPreview] = useDebouncedState<{ emoji: string; name: string } | null>(null, 100);
const [recentEmojis, setRecentEmojis] = useLocalStorage<string[]>('emoji.recent', []);
const [actualTone, setActualTone] = useLocalStorage('emoji.tone', 0);
const [emojiListByCategory, setEmojiListByCategory] = useState<EmojiByCategory[]>([]);
const [currentCategory, setCurrentCategory] = useState('recent');
const [customItemsLimit, setCustomItemsLimit] = useState(DEFAULT_ITEMS_LIMIT);
// TODO: improve this update
const updateEmojiListByCategory = useCallback(
(categoryKey: string, limit: number = DEFAULT_ITEMS_LIMIT) => {
const result = emojiListByCategory.map((category) => {
return categoryKey === category.key
? {
...category,
emojis: {
list: createEmojiList(category.key, null, recentEmojis, setRecentEmojis),
limit: category.key === CUSTOM_CATEGORY ? limit | customItemsLimit : null,
},
}
: category;
});
setEmojiListByCategory(result);
},
[customItemsLimit, emojiListByCategory, recentEmojis, setRecentEmojis],
);
const addRecentEmoji = useCallback(
(_emoji: string) => {
const recent = recentEmojis || [];
const pos = recent.indexOf(_emoji as never);
if (pos !== -1) {
recent.splice(pos, 1);
}
recent.unshift(_emoji);
// limit recent emojis to 27 (3 rows of 9)
recent.splice(27);
setRecentEmojis(recent);
emoji.packages.base.emojisByCategory.recent = recent;
updateEmojiListByCategory('recent');
},
[recentEmojis, setRecentEmojis, updateEmojiListByCategory],
);
useEffect(() => {
if (recentEmojis?.length > 0) {
updateRecent(recentEmojis);
}
const emojis = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis);
setEmojiListByCategory(emojis);
}, [actualTone, recentEmojis, customItemsLimit, currentCategory, setRecentEmojis]);
const open = useCallback((ref: Element, callback: (emoji: string) => void) => {
return setEmojiPicker(<EmojiPicker reference={ref} onClose={() => setEmojiPicker(null)} onPickEmoji={(emoji) => callback(emoji)} />);
......@@ -21,8 +80,32 @@ const EmojiPickerProvider = ({ children }: { children: ReactNode }): ReactElemen
emojiToPreview,
handlePreview: (emoji: string, name: string) => setEmojiToPreview({ emoji, name }),
handleRemovePreview: () => setEmojiToPreview(null),
addRecentEmoji,
emojiListByCategory,
recentEmojis,
setRecentEmojis,
actualTone,
currentCategory,
setCurrentCategory,
customItemsLimit,
setCustomItemsLimit,
setActualTone,
}),
[emojiPicker, open, emojiToPreview, setEmojiToPreview],
[
emojiPicker,
open,
emojiToPreview,
setEmojiToPreview,
addRecentEmoji,
emojiListByCategory,
recentEmojis,
setRecentEmojis,
actualTone,
currentCategory,
setCurrentCategory,
customItemsLimit,
setActualTone,
],
);
return (
......
......@@ -57,14 +57,20 @@ const EmojiCategoryRow = ({
<>
{categoryKey === CUSTOM_CATEGORY &&
emojis.list.map(
({ emoji, image }, index = 1) =>
({ emoji, image, emojiHandle }, index = 1) =>
index < customItemsLimit && (
<EmojiElement key={emoji + categoryKey} emoji={emoji} image={image} onClick={handleSelectEmoji} />
<EmojiElement
key={emoji + categoryKey}
emoji={emoji}
image={image}
emojiHandle={emojiHandle}
onClick={handleSelectEmoji}
/>
),
)}
{!(categoryKey === CUSTOM_CATEGORY) &&
emojis.list.map(({ emoji, image }) => (
<EmojiElement key={emoji + categoryKey} emoji={emoji} image={image} onClick={handleSelectEmoji} />
emojis.list.map(({ emoji, image, emojiHandle }) => (
<EmojiElement key={emoji + categoryKey} emoji={emoji} image={image} emojiHandle={emojiHandle} onClick={handleSelectEmoji} />
))}
</>
</EmojiPickerCategoryWrapper>
......
import { Box, IconButton } from '@rocket.chat/fuselage';
import { css } from '@rocket.chat/css-in-js';
import { IconButton } from '@rocket.chat/fuselage';
import type { MouseEvent, AllHTMLAttributes } from 'react';
import React, { memo } from 'react';
import type { EmojiItem } from '../../../../app/emoji/client';
import Emoji from '../../../components/Emoji';
import { usePreviewEmoji } from '../../../contexts/EmojiPickerContext';
type EmojiElementProps = EmojiItem & { onClick: (e: MouseEvent<HTMLElement>) => void } & Omit<AllHTMLAttributes<HTMLButtonElement>, 'is'>;
type EmojiElementProps = EmojiItem & { small?: boolean; onClick: (e: MouseEvent<HTMLElement>) => void } & Omit<
AllHTMLAttributes<HTMLButtonElement>,
'is'
>;
const EmojiElement = ({ emoji, image, onClick, ...props }: EmojiElementProps) => {
const EmojiElement = ({ emoji, image, emojiHandle, onClick, small = false, ...props }: EmojiElementProps) => {
const { handlePreview, handleRemovePreview } = usePreviewEmoji();
if (!image) {
return null;
}
const emojiElement = <Box dangerouslySetInnerHTML={{ __html: image }} />;
const emojiSmallClass = css`
> .emoji,
.emojione {
width: 18px;
height: 18px;
}
`;
const emojiElement = <Emoji emojiHandle={emojiHandle} />;
return (
<IconButton
{...props}
medium
{...(small && { className: emojiSmallClass })}
small={small}
medium={!small}
onMouseOver={() => handlePreview(image, emoji)}
onMouseLeave={handleRemovePreview}
onClick={onClick}
......
import { TextInput, Icon, Button, Divider } from '@rocket.chat/fuselage';
import { useLocalStorage, useMediaQuery, useOutsideClick } from '@rocket.chat/fuselage-hooks';
import { useMediaQuery, useOutsideClick } from '@rocket.chat/fuselage-hooks';
import {
EmojiPickerCategoryHeader,
EmojiPickerContainer,
......@@ -14,17 +14,9 @@ import type { ChangeEvent, KeyboardEvent, MouseEvent, RefObject } from 'react';
import React, { useLayoutEffect, useState, useEffect, useRef, useCallback } from 'react';
import type { VirtuosoHandle } from 'react-virtuoso';
import type { EmojiItem, EmojiByCategory, EmojiCategoryPosition } from '../../../../app/emoji/client';
import {
emoji,
updateRecent,
getCategoriesList,
createEmojiList,
getEmojisBySearchTerm,
createPickerEmojis,
CUSTOM_CATEGORY,
} from '../../../../app/emoji/client';
import { usePreviewEmoji } from '../../../contexts/EmojiPickerContext';
import type { EmojiItem, EmojiCategoryPosition } from '../../../../app/emoji/client';
import { emoji, getCategoriesList, getEmojisBySearchTerm } from '../../../../app/emoji/client';
import { usePreviewEmoji, useEmojiPickerData } from '../../../contexts/EmojiPickerContext';
import { useIsVisible } from '../../room/hooks/useIsVisible';
import CategoriesResult from './CategoriesResult';
import EmojiPickerCategoryItem from './EmojiPickerCategoryItem';
......@@ -39,8 +31,6 @@ type EmojiPickerProps = {
onPickEmoji: (emoji: string) => void;
};
const DEFAULT_ITEMS_LIMIT = 90;
const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => {
const t = useTranslation();
......@@ -53,21 +43,27 @@ const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => {
const isInputVisible = useIsVisible(textInputRef);
const emojiCategories = getCategoriesList();
const [emojiListByCategory, setEmojiListByCategory] = useState<EmojiByCategory[]>([]);
const canManageEmoji = usePermission('manage-emoji');
const customEmojiRoute = useRoute('emoji-custom');
const [recentEmojis, setRecentEmojis] = useLocalStorage<string[]>('emoji.recent', []);
const [actualTone, setActualTone] = useLocalStorage('emoji.tone', 0);
const [searching, setSearching] = useState(false);
const [customItemsLimit, setCustomItemsLimit] = useState(DEFAULT_ITEMS_LIMIT);
const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState<EmojiItem[]>([]);
const [currentCategory, setCurrentCategory] = useState('recent');
const { emojiToPreview, handleRemovePreview } = usePreviewEmoji();
const {
recentEmojis,
setCurrentCategory,
addRecentEmoji,
setRecentEmojis,
actualTone,
currentCategory,
emojiListByCategory,
customItemsLimit,
setActualTone,
setCustomItemsLimit,
} = useEmojiPickerData();
useEffect(() => () => handleRemovePreview(), []);
......@@ -98,47 +94,12 @@ const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => {
};
}, [reference]);
const showInitialCategory = useCallback((customEmojiList) => {
handleGoToCategory(customEmojiList.length > 0 ? 0 : 1);
}, []);
useEffect(() => {
if (textInputRef.current && isInputVisible) {
textInputRef.current.focus();
}
}, [isInputVisible]);
useEffect(() => {
if (recentEmojis?.length > 0) {
updateRecent(recentEmojis);
}
const emojis = createPickerEmojis(customItemsLimit, actualTone, recentEmojis, setRecentEmojis);
setEmojiListByCategory(emojis);
if (recentEmojis.length === 0 && currentCategory === 'recent') {
const customEmojiList = emojis.filter(({ key }) => key === 'rocket');
showInitialCategory(customEmojiList);
}
}, [actualTone, recentEmojis, customItemsLimit, currentCategory, setRecentEmojis, showInitialCategory]);
// TODO: improve this update
const updateEmojiListByCategory = (categoryKey: string, limit: number = DEFAULT_ITEMS_LIMIT) => {
const result = emojiListByCategory.map((category) => {
return categoryKey === category.key
? {
...category,
emojis: {
list: createEmojiList(category.key, null, recentEmojis, setRecentEmojis),
limit: category.key === CUSTOM_CATEGORY ? limit | customItemsLimit : null,
},
}
: category;
});
setEmojiListByCategory(result);
};
const handleSelectEmoji = (event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
......@@ -165,23 +126,16 @@ const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => {
onClose();
};
const addRecentEmoji = (_emoji: string) => {
const recent = recentEmojis || [];
const pos = recent.indexOf(_emoji as never);
const showInitialCategory = useCallback((customEmojiList) => {
handleGoToCategory(customEmojiList.length > 0 ? 0 : 1);
}, []);
if (pos !== -1) {
recent.splice(pos, 1);
useEffect(() => {
if (recentEmojis.length === 0 && currentCategory === 'recent') {
const customEmojiList = emojiListByCategory.filter(({ key }) => key === 'rocket');
showInitialCategory(customEmojiList);
}
recent.unshift(_emoji);
// limit recent emojis to 27 (3 rows of 9)
recent.splice(27);
setRecentEmojis(recent);
emoji.packages.base.emojisByCategory.recent = recent;
updateEmojiListByCategory('recent');
};
}, [actualTone, recentEmojis, emojiListByCategory, currentCategory, setRecentEmojis, showInitialCategory]);
const handleSearch = (e: ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
......@@ -203,7 +157,7 @@ const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => {
};
const handleLoadMore = () => {
setCustomItemsLimit((prevState) => prevState + 90);
setCustomItemsLimit(customItemsLimit + 90);
};
// FIXME: not able to type the event scroll yet due the virtuoso version
......
......@@ -37,8 +37,8 @@ const SearchingResult = ({ searchResults, handleSelectEmoji }: SearchingResultPr
List: SearchingResultWrapper,
}}
itemContent={(index) => {
const { emoji, image } = searchResults[index] || {};
return <EmojiElement emoji={emoji} image={image} onClick={handleSelectEmoji} />;
const { emoji, image, emojiHandle } = searchResults[index] || {};
return <EmojiElement emoji={emoji} image={image} emojiHandle={emojiHandle} onClick={handleSelectEmoji} />;
}}
/>
);
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment