Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion examples/vite/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@ import {
ReactionsList,
WithDragAndDropUpload,
useChatContext,
defaultReactionOptions,
ReactionOptions,
mapEmojiMartData,
} from 'stream-chat-react';
import { createTextComposerEmojiMiddleware, EmojiPicker } from 'stream-chat-react/emojis';
import { init, SearchIndex } from 'emoji-mart';
import data from '@emoji-mart/data';
import data from '@emoji-mart/data/sets/14/native.json';
import { humanId } from 'human-id';
import { chatViewSelectorItemSet } from './Sidebar/ChatViewSelectorItemSet.tsx';
import { useAppSettingsState } from './AppSettings';
Expand Down Expand Up @@ -69,6 +72,11 @@ const sort: ChannelSort = { last_message_at: -1, updated_at: -1 };
// @ts-ignore
const isMessageAIGenerated = (message: LocalMessage) => !!message?.ai_generated;

const newReactionOptions: ReactionOptions = {
...defaultReactionOptions,
extended: mapEmojiMartData(data),
};

const useUser = () => {
const userId = useMemo(() => {
return (
Expand Down Expand Up @@ -184,6 +192,7 @@ const App = () => {
emojiSearchIndex: SearchIndex,
EmojiPicker,
ReactionsList: CustomMessageReactions,
reactionOptions: newReactionOptions,
}}
>
<Chat client={chatClient} isMessageAIGenerated={isMessageAIGenerated}>
Expand Down
115 changes: 82 additions & 33 deletions src/components/Reactions/ReactionSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import clsx from 'clsx';

import { useDialog } from '../Dialog';
Expand All @@ -24,6 +24,7 @@ const stableOwnReactions: ReactionResponse[] = [];

const UnMemoizedReactionSelector = (props: ReactionSelectorProps) => {
const { handleReaction: propHandleReaction, own_reactions: propOwnReactions } = props;
const [extendedListOpen, setExtendedListOpen] = useState(false);

const { reactionOptions = defaultReactionOptions } =
useComponentContext('ReactionSelector');
Expand All @@ -49,40 +50,88 @@ const UnMemoizedReactionSelector = (props: ReactionSelectorProps) => {
return map;
}, [ownReactions]);

const adjustedQuickReactionOptions = useMemo(() => {
if (Array.isArray(reactionOptions)) return reactionOptions;

return Object.entries(reactionOptions.quick).map(
([type, { Component, name, unicode }]) => ({
Component,
name,
type,
unicode,
}),
);
}, [reactionOptions]);

return (
<div className='str-chat__reaction-selector' data-testid='reaction-selector'>
<ul className='str-chat__reaction-selector-list'>
{reactionOptions.map(({ Component, name: reactionName, type: reactionType }) => (
<li className='str-chat__reaction-list-selector__item' key={reactionType}>
<button
aria-label={`Select Reaction: ${reactionName || reactionType}`}
aria-pressed={typeof ownReactionByType[reactionType] !== 'undefined'}
className={clsx('str-chat__reaction-selector-list__item-button')}
data-testid='select-reaction-button'
data-text={reactionType}
onClick={(event) => {
handleReaction(reactionType, event);
if (closeReactionSelectorOnClick) {
dialog.close();
}
}}
>
<span className='str-chat__reaction-icon'>
<Component />
</span>
</button>
</li>
))}
</ul>
<Button
appearance='outline'
circular
className='str-chat__reaction-selector__add-button'
size='sm'
variant='secondary'
>
<IconPlusLarge />
</Button>
{!extendedListOpen && (
<>
<ul className='str-chat__reaction-selector-list'>
{adjustedQuickReactionOptions.map(
({ Component, name: reactionName, type: reactionType }) => (
<li className='str-chat__reaction-list-selector__item' key={reactionType}>
<button
aria-label={`Select Reaction: ${reactionName || reactionType}`}
aria-pressed={typeof ownReactionByType[reactionType] !== 'undefined'}
className={clsx('str-chat__reaction-selector-list__item-button')}
data-testid='select-reaction-button'
data-text={reactionType}
onClick={(event) => {
handleReaction(reactionType, event);
if (closeReactionSelectorOnClick) {
dialog.close();
}
}}
>
<span className='str-chat__reaction-icon'>
<Component />
</span>
</button>
</li>
),
)}
</ul>
<Button
appearance='outline'
circular
className='str-chat__reaction-selector__add-button'
onClick={() => setExtendedListOpen(true)}
size='sm'
variant='secondary'
>
<IconPlusLarge />
</Button>
</>
)}
{extendedListOpen &&
!Array.isArray(reactionOptions) &&
reactionOptions.extended && (
<div className='str-chat__reaction-selector-extended-list'>
{Object.entries(reactionOptions.extended).map(
([reactionType, { Component, name: reactionName }]) => (
<button
aria-label={`Select Reaction: ${reactionName || reactionType}`}
aria-pressed={typeof ownReactionByType[reactionType] !== 'undefined'}
className='str-chat__reaction-selector-extended-list__button str-chat__button str-chat__button--ghost str-chat__button--size-sm str-chat__button--circular'
data-testid='select-reaction-button'
data-text={reactionType}
key={reactionType}
onClick={(event) => {
handleReaction(reactionType, event);
if (closeReactionSelectorOnClick) {
dialog.close();
}
}}
>
<span className='str-chat__reaction-icon'>
<Component />
</span>
</button>
),
)}
</div>
)}
</div>
);
};
Expand Down
1 change: 1 addition & 0 deletions src/components/Reactions/ReactionSelectorWithButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const ReactionSelectorWithButton = ({
placement={isMyMessage() ? 'top-end' : 'top-start'}
referenceElement={buttonRef.current}
trapFocus
updatePositionOnContentResize
>
<ReactionSelector />
</DialogAnchor>
Expand Down
29 changes: 25 additions & 4 deletions src/components/Reactions/hooks/useProcessReactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,35 @@ export const useProcessReactions = (params: UseProcessReactionsParams) => {
);

const getEmojiByReactionType = useCallback(
(reactionType: string) =>
reactionOptions.find(({ type }) => type === reactionType)?.Component ?? null,
(reactionType: string) => {
if (Array.isArray(reactionOptions)) {
return (
reactionOptions.find(({ type }) => type === reactionType)?.Component ?? null
);
}

return (
reactionOptions.quick[reactionType]?.Component ??
reactionOptions.extended?.[reactionType]?.Component ??
null
);
},
[reactionOptions],
);

const isSupportedReaction = useCallback(
(reactionType: string) =>
reactionOptions.some((reactionOption) => reactionOption.type === reactionType),
(reactionType: string) => {
if (Array.isArray(reactionOptions)) {
return reactionOptions.some(
(reactionOption) => reactionOption.type === reactionType,
);
}

return (
typeof reactionOptions.quick[reactionType] !== 'undefined' ||
typeof reactionOptions.extended?.[reactionType] !== 'undefined'
);
},
[reactionOptions],
);

Expand Down
126 changes: 98 additions & 28 deletions src/components/Reactions/reactionOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,107 @@

import React from 'react';

export type ReactionOptions = Array<{
type LegacyReactionOptions = Array<{
Component: React.ComponentType;
type: string;
name?: string;
}>;

export const defaultReactionOptions: ReactionOptions = [
{
type: 'haha',
Component: () => <>😂</>,
name: 'Joy',
},
{
type: 'like',
Component: () => <>👍</>,
name: 'Thumbs up',
},
{
type: 'love',
Component: () => <>❤️</>,
name: 'Heart',
},
{ type: 'sad', Component: () => <>😔</>, name: 'Sad' },
{
type: 'wow',
Component: () => <>😮</>,
name: 'Astonished',
},
{
type: 'fire',
Component: () => <>🔥</>,
name: 'Fire',
type ReactionOptionData = {
Component: React.ComponentType;
name?: string;
unicode?: string;
};

export type ReactionOptions =
| LegacyReactionOptions
| {
quick: {
[key: string]: ReactionOptionData;
};
extended?: {
[key: string]: ReactionOptionData;
};
};

export const mapEmojiMartData = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
emojiMartData: any,
): NonNullable<Exclude<ReactionOptions, LegacyReactionOptions>['extended']> => {
if (!emojiMartData || !emojiMartData.emojis) {
return {};
}

const newMap: ReturnType<typeof mapEmojiMartData> = {};

for (const emojiId in emojiMartData.emojis) {
const emojiData = emojiMartData.emojis[emojiId];
const [firstEmoji] = emojiData.skins;

if (!firstEmoji || !firstEmoji.native) continue;

const nativeEmoji = firstEmoji.native as string;

const unicode = emojiToUnicode(nativeEmoji);

newMap[unicode] = {
Component: () => <>{nativeEmoji}</>,
name: emojiData.name,
};
}

return newMap;
};

export type AdvancedReactionOptions = {
quick: ReactionOptions;
extended: ReactionOptions;
};

export const emojiToUnicode = (emoji: string) => {
const unicodeStrings = [];
for (const c of emoji) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const codePoint = c.codePointAt(0)!;
unicodeStrings.push(`U+${codePoint.toString(16).toUpperCase().padStart(4, '0')}`);
}

return unicodeStrings.join('-');
};

export const unicodeToEmoji = (unicode: string) =>
unicode
.split('-')
.map((code) => String.fromCodePoint(parseInt(code.replace('U+', ''), 16)))
.join('');

export const defaultReactionOptions: ReactionOptions = {
quick: {
haha: {
Component: () => <>😂</>,
name: 'Joy',
unicode: emojiToUnicode('😂'),
},
like: {
Component: () => <>👍</>,
name: 'Thumbs up',
unicode: emojiToUnicode('👍'),
},
love: {
Component: () => <>❤️</>,
name: 'Heart',
unicode: emojiToUnicode('❤️'),
},
sad: { Component: () => <>😔</>, name: 'Sad', unicode: emojiToUnicode('😔') },
wow: {
Component: () => <>😮</>,
name: 'Astonished',
unicode: emojiToUnicode('😮'),
},
fire: {
Component: () => <>🔥</>,
name: 'Fire',
unicode: emojiToUnicode('🔥'),
},
},
];
};
32 changes: 32 additions & 0 deletions src/components/Reactions/styling/ReactionSelector.scss
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@
/* shadow/ios/light/elevation-3 */
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.16);

&:has(.str-chat__reaction-selector-extended-list) {
padding: 0;
display: block;
overflow-y: auto;
scrollbar-width: none;
border-radius: var(--radius-lg);
max-height: 250px;
}

.str-chat__reaction-selector__add-button {
width: 32px;
aspect-ratio: 1/1;
Expand All @@ -23,6 +32,29 @@
}
}

.str-chat__reaction-selector-extended-list {
display: grid;
grid-template-columns: repeat(7, 1fr);
height: 100%;
overflow-y: auto;
padding-block: var(--spacing-md);
padding-inline: var(--spacing-sm);

.str-chat__reaction-selector-extended-list__button {
.str-chat__reaction-icon {
height: 24px;
width: 24px;
font-size: 24px;
letter-spacing: 0;
line-height: 0;
font-family: system-ui;
display: flex;
justify-content: center;
align-items: center;
}
}
}

.str-chat__reaction-selector-list {
list-style: none;
margin: var(--spacing-none, 0);
Expand Down
Loading