diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts index e81ec1ae5..75a713af4 100644 --- a/src/components/Dialog/hooks/useDialog.ts +++ b/src/components/Dialog/hooks/useDialog.ts @@ -22,16 +22,17 @@ export const useDialog = ({ }: UseDialogParams) => { const { dialogManager } = useDialogManager({ dialogManagerId }); - useEffect( - () => () => { + useEffect(() => { + dialogManager.cancelPendingRemoval(id); + + return () => { // Since this cleanup can run even if the component is still mounted // and dialog id is unchanged (e.g. in ), it's safer to // mark state as unused and only remove it after a timeout, rather than // to remove it immediately. dialogManager.markForRemoval(id); - }, - [dialogManager, id], - ); + }; + }, [dialogManager, id]); return dialogManager.getOrCreate({ closeOnClickOutside, id }); }; diff --git a/src/components/Dialog/service/DialogManager.ts b/src/components/Dialog/service/DialogManager.ts index 6fb2470e2..1f11a826c 100644 --- a/src/components/Dialog/service/DialogManager.ts +++ b/src/components/Dialog/service/DialogManager.ts @@ -70,12 +70,12 @@ export class DialogManager { ); } - get(id: DialogId) { + get(id: DialogId): Dialog | undefined { return this.state.getLatestValue().dialogsById[id]; } getOrCreate({ closeOnClickOutside, id }: GetOrCreateDialogParams) { - let dialog = this.state.getLatestValue().dialogsById[id]; + let dialog = this.get(id); if (!dialog) { dialog = { close: () => { @@ -97,7 +97,7 @@ export class DialogManager { }; this.state.next((current) => ({ ...current, - dialogsById: { ...current.dialogsById, [id]: dialog }, + dialogsById: { ...current.dialogsById, [id]: dialog as Dialog }, })); } @@ -106,16 +106,15 @@ export class DialogManager { if (shouldUpdateDialogSettings) { if (dialog.removalTimeout) clearTimeout(dialog.removalTimeout); - dialog = { - ...dialog, - closeOnClickOutside, - removalTimeout: undefined, - }; this.state.next((current) => ({ ...current, dialogsById: { ...current.dialogsById, - [id]: dialog, + [id]: { + ...current.dialogsById[id], + closeOnClickOutside, + removalTimeout: undefined, + }, }, })); } @@ -158,9 +157,8 @@ export class DialogManager { } } - remove(id: DialogId) { - const state = this.state.getLatestValue(); - const dialog = state.dialogsById[id]; + remove = (id: DialogId) => { + const dialog = this.get(id); if (!dialog) return; if (dialog.removalTimeout) { @@ -175,7 +173,7 @@ export class DialogManager { dialogsById: newDialogs, }; }); - } + }; /** * Marks the dialog state as unused. If the dialog id is referenced again quickly, @@ -183,7 +181,7 @@ export class DialogManager { * a short timeout. */ markForRemoval(id: DialogId) { - const dialog = this.state.getLatestValue().dialogsById[id]; + const dialog = this.get(id); if (!dialog) { return; @@ -202,4 +200,25 @@ export class DialogManager { }, })); } + + cancelPendingRemoval(id: DialogId) { + const dialog = this.get(id); + + if (!dialog?.removalTimeout) { + return; + } + + clearTimeout(dialog.removalTimeout); + + this.state.next((current) => ({ + ...current, + dialogsById: { + ...current.dialogsById, + [id]: { + ...current.dialogsById[id], + removalTimeout: undefined, + }, + }, + })); + } } diff --git a/src/components/Icons/icons.tsx b/src/components/Icons/icons.tsx index 1722cd406..4acbac479 100644 --- a/src/components/Icons/icons.tsx +++ b/src/components/Icons/icons.tsx @@ -495,6 +495,32 @@ export const IconEmoji = createIcon( , ); +export const IconEmojiAdd = createIcon( + 'IconEmojiAdd', + <> + + + + + + , +); + // was: IconExclamation export const IconExclamationMarkFill = createIcon( 'IconExclamationMarkFill', diff --git a/src/components/Message/styling/Message.scss b/src/components/Message/styling/Message.scss index 07d94fb22..06d23b456 100644 --- a/src/components/Message/styling/Message.scss +++ b/src/components/Message/styling/Message.scss @@ -278,9 +278,7 @@ var(--str-chat__message-reactions-host-offset-x) * -1 ); - &:has(.str-chat__message-reactions--flipped-horizontally) { - margin-inline-end: var(--str-chat__message-reactions-host-offset-x); - } + margin-inline-end: var(--str-chat__message-reactions-host-offset-x); } .str-chat__message-reactions.str-chat__message-reactions--segmented.str-chat__message-reactions--bottom @@ -323,9 +321,7 @@ &:has(.str-chat__message-reactions--top) { padding-inline-end: calc(var(--str-chat__message-reactions-host-offset-x) * -1); - &:has(.str-chat__message-reactions--flipped-horizontally) { - margin-inline-start: var(--str-chat__message-reactions-host-offset-x); - } + margin-inline-start: var(--str-chat__message-reactions-host-offset-x); } .str-chat__message-reactions.str-chat__message-reactions--segmented.str-chat__message-reactions--bottom diff --git a/src/components/Reactions/MessageReactions.tsx b/src/components/Reactions/MessageReactions.tsx index 036444422..165646e1c 100644 --- a/src/components/Reactions/MessageReactions.tsx +++ b/src/components/Reactions/MessageReactions.tsx @@ -66,7 +66,6 @@ const UnMemoizedMessageReactions = (props: MessageReactionsProps) => { capLimit: { clustered: capLimitClustered = 5, segmented: capLimitSegmented = 4 } = {}, flipHorizontalPosition = false, handleFetchReactions, - // eslint-disable-next-line @typescript-eslint/no-unused-vars reactionDetailsSort, verticalPosition = 'top', visualStyle = 'clustered', @@ -89,7 +88,9 @@ const UnMemoizedMessageReactions = (props: MessageReactionsProps) => { const { isMyMessage, message } = useMessageContext('MessageReactions'); const divRef = useRef>(null); - const dialogId = `message-reactions-detail-${message.id}`; + const dialogId = DefaultMessageReactionsDetail.getDialogId({ + messageId: message.id, + }); const { dialog, dialogManager } = useDialogOnNearestManager({ id: dialogId }); const isDialogOpen = useDialogIsOpen(dialogId, dialogManager?.id); @@ -158,6 +159,7 @@ const UnMemoizedMessageReactions = (props: MessageReactionsProps) => { aria-pressed={isDialogOpen} buttonIf={visualStyle === 'clustered'} className='str-chat__message-reactions__list-button' + data-testid='message-reactions-list-button' onClick={() => handleReactionButtonClick(null)} >
    @@ -166,6 +168,7 @@ const UnMemoizedMessageReactions = (props: MessageReactionsProps) => { EmojiComponent && (
  • { className='str-chat__message-reactions__list-item-button' onClick={() => handleReactionButtonClick(reactionType)} > - + {visualStyle === 'segmented' && reactionCount > 1 && ( @@ -206,7 +212,10 @@ const UnMemoizedMessageReactions = (props: MessageReactionsProps) => { )}
{visualStyle === 'clustered' && ( - + {totalReactionCount} )} @@ -225,6 +234,7 @@ const UnMemoizedMessageReactions = (props: MessageReactionsProps) => { @@ -24,7 +26,7 @@ export type MessageReactionsDetailProps = Partial< sort?: ReactionSort; totalReactionCount?: number; reactionGroups?: ReturnType['reactionGroups']; -}; +} & ReactionSelectorProps; const defaultReactionDetailsSort = { created_at: -1 } as const; @@ -43,25 +45,36 @@ export const MessageReactionsDetailLoadingIndicator = () => { return <>{elements}; }; -export function MessageReactionsDetail({ +interface MessageReactionsDetailInterface { + (props: MessageReactionsDetailProps): React.ReactNode; + displayName: string; + getDialogId: (_: { messageId: string }) => string; +} + +export const MessageReactionsDetail: MessageReactionsDetailInterface = ({ handleFetchReactions, + handleReaction, onSelectedReactionTypeChange, + own_reactions, reactionDetailsSort: propReactionDetailsSort, reactionGroups, reactions, selectedReactionType, totalReactionCount, -}: MessageReactionsDetailProps) { +}) => { + const [extendedReactionListOpen, setExtendedReactionListOpen] = useState(false); const { client } = useChatContext(); const { Avatar = DefaultAvatar, LoadingIndicator = MessageReactionsDetailLoadingIndicator, reactionOptions = defaultReactionOptions, + ReactionSelectorExtendedList = ReactionSelector.ExtendedList, } = useComponentContext(MessageReactionsDetail.name); const { t } = useTranslationContext(); const { handleReaction: contextHandleReaction, + message, reactionDetailsSort: contextReactionDetailsSort, } = useMessageContext(MessageReactionsDetail.name); @@ -79,6 +92,21 @@ export function MessageReactionsDetail({ sort: reactionDetailsSort, }); + if (extendedReactionListOpen) { + return ( +
+ +
+ ); + } + return (
)}
-
    +
      +
    • + +
    • + {reactions.map( ({ EmojiComponent, reactionCount, reactionType }) => EmojiComponent && ( @@ -110,84 +154,92 @@ export function MessageReactionsDetail({ - {reactionCount > 1 && ( - - {reactionCount} - - )} + + {reactionCount} + ), )}
-
- {areReactionsLoading && } - {!areReactionsLoading && ( - <> - {reactionDetails.map(({ type, user }) => { - const belongsToCurrentUser = client.user?.id === user?.id; - const EmojiComponent = Array.isArray(reactionOptions) - ? undefined - : (reactionOptions.quick[type]?.Component ?? - reactionOptions.extended?.[type]?.Component); - - return ( -
- -
- - {belongsToCurrentUser ? t('You') : user?.name || user?.id} - - {belongsToCurrentUser && ( - - )} + {belongsToCurrentUser ? t('You') : user?.name || user?.id} + + {belongsToCurrentUser && ( + + )} +
+ + {!selectedReactionType && EmojiComponent && } +
- - {EmojiComponent && !selectedReactionType && } - -
- ); - })} - - )} + ); + })} + + )} +
); -} +}; + +MessageReactionsDetail.displayName = 'MessageReactionsDetail'; + +MessageReactionsDetail.getDialogId = ({ messageId }) => + `message-reactions-detail-${messageId}`; diff --git a/src/components/Reactions/ReactionSelector.tsx b/src/components/Reactions/ReactionSelector.tsx index 9679fa0e8..2d348b1fd 100644 --- a/src/components/Reactions/ReactionSelector.tsx +++ b/src/components/Reactions/ReactionSelector.tsx @@ -1,7 +1,7 @@ import React, { type ReactNode, useMemo, useState } from 'react'; import clsx from 'clsx'; -import { useDialog } from '../Dialog'; +import { useDialogOnNearestManager } from '../Dialog'; import { defaultReactionOptions } from './reactionOptions'; import { useComponentContext } from '../../context/ComponentContext'; import { useMessageContext } from '../../context/MessageContext'; @@ -24,6 +24,7 @@ interface ReactionSelectorInterface { (props: ReactionSelectorProps): ReactNode; getDialogId: (_: { messageId: string; threadList?: boolean }) => string; displayName: string; + ExtendedList: React.ComponentType; } const stableOwnReactions: ReactionResponse[] = []; @@ -32,8 +33,10 @@ export const ReactionSelector: ReactionSelectorInterface = (props) => { const { handleReaction: propHandleReaction, own_reactions: propOwnReactions } = props; const [extendedListOpen, setExtendedListOpen] = useState(false); - const { reactionOptions = defaultReactionOptions } = - useComponentContext('ReactionSelector'); + const { + reactionOptions = defaultReactionOptions, + ReactionSelectorExtendedList = ReactionSelector.ExtendedList, + } = useComponentContext('ReactionSelector'); const { closeReactionSelectorOnClick, @@ -45,7 +48,7 @@ export const ReactionSelector: ReactionSelectorInterface = (props) => { messageId: message.id, threadList, }); - const dialog = useDialog({ id: dialogId }); + const { dialog } = useDialogOnNearestManager({ id: dialogId }); const handleReaction = propHandleReaction ?? contextHandleReaction; const ownReactions = propOwnReactions ?? message?.own_reactions ?? stableOwnReactions; @@ -77,7 +80,10 @@ export const ReactionSelector: ReactionSelectorInterface = (props) => {
{!extendedListOpen && ( <> -
    +
      {adjustedQuickReactionOptions.map( ({ Component, name: reactionName, type: reactionType }) => (
    • @@ -106,6 +112,7 @@ export const ReactionSelector: ReactionSelectorInterface = (props) => { appearance='outline' circular className='str-chat__reaction-selector__add-button' + data-testid='reaction-selector-add-button' onClick={() => setExtendedListOpen(true)} size='sm' variant='secondary' @@ -114,34 +121,9 @@ export const ReactionSelector: ReactionSelectorInterface = (props) => { )} - {extendedListOpen && - !Array.isArray(reactionOptions) && - reactionOptions.extended && ( -
      - {Object.entries(reactionOptions.extended).map( - ([reactionType, { Component, name: reactionName }]) => ( - - ), - )} -
      - )} + {extendedListOpen && ( + + )}
); }; @@ -152,3 +134,67 @@ ReactionSelector.getDialogId = ({ messageId, threadList }) => { }; ReactionSelector.displayName = 'ReactionSelector'; + +ReactionSelector.ExtendedList = function ReactionSelectorExtendedList({ + dialogId, + handleReaction: propHandleReaction, + own_reactions: propOwnReactions, +}) { + const { reactionOptions = defaultReactionOptions } = useComponentContext( + 'ReactionSelector.ExtendedList', + ); + const { + closeReactionSelectorOnClick, + handleReaction: contextHandleReaction, + message, + } = useMessageContext('ReactionSelector'); + + const handleReaction = propHandleReaction ?? contextHandleReaction; + const ownReactions = propOwnReactions ?? message?.own_reactions ?? stableOwnReactions; + + const { dialog } = useDialogOnNearestManager({ id: dialogId || '' }); + + const ownReactionByType = useMemo(() => { + const map: { [key: string]: ReactionResponse } = {}; + + for (const reaction of ownReactions) { + map[reaction.type] ??= reaction; + } + + return map; + }, [ownReactions]); + + if (Array.isArray(reactionOptions) || !reactionOptions.extended) { + return null; + } + + return ( +
+ {Object.entries(reactionOptions.extended).map( + ([reactionType, { Component, name: reactionName }]) => ( + + ), + )} +
+ ); +}; diff --git a/src/components/Reactions/__tests__/MessageReactions.test.tsx b/src/components/Reactions/__tests__/MessageReactions.test.tsx index 6cc3d9ca7..e97961c83 100644 --- a/src/components/Reactions/__tests__/MessageReactions.test.tsx +++ b/src/components/Reactions/__tests__/MessageReactions.test.tsx @@ -1,14 +1,18 @@ import React from 'react'; import { render } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; import { axe } from '../../../../axe-helper'; -import { MessageReactions } from '../MessageReactions'; +import { MessageReactions, type MessageReactionsProps } from '../MessageReactions'; import { MessageProvider } from '../../../context/MessageContext'; import { generateReaction, mockMessageContext } from '../../../mock-builders'; import { DialogManagerProvider, WithComponents } from '../../../context'; -import { defaultReactionOptions } from '../reactionOptions'; +import { defaultReactionOptions, type ReactionOptions } from '../reactionOptions'; + +import type { ReactionGroupResponse } from 'stream-chat'; +import type { ReactionsComparator } from '../types'; const USER_ID = 'mark'; @@ -16,8 +20,12 @@ const renderComponent = ({ reaction_groups = {}, reactionOptions = defaultReactionOptions, ...props -}: any = {}) => { - const reactions = Object.entries(reaction_groups).flatMap(([type, { count }]: any) => +}: { + reaction_groups?: Record; + reactionOptions?: ReactionOptions; + sortReactions?: ReactionsComparator; +} & Partial> = {}) => { + const reactions = Object.entries(reaction_groups).flatMap(([type, { count }]) => Array.from({ length: count }, (_, i) => generateReaction({ type, user: { id: `${USER_ID}-${i}` } }), ), @@ -50,13 +58,13 @@ describe('MessageReactions', () => { vi.spyOn(console, 'warn').mockImplementation(null); it('should render the total reaction count in clustered mode', async () => { - const { container } = renderComponent({ + const { container, getByTestId } = renderComponent({ reaction_groups: { - haha: { count: 2 }, - love: { count: 5 }, + haha: fromPartial({ count: 2 }), + love: fromPartial({ count: 5 }), }, }); - const countEl = container.querySelector('.str-chat__message-reactions__total-count'); + const countEl = getByTestId('message-reactions-total-count'); expect(countEl).toBeInTheDocument(); expect(countEl).toHaveTextContent('7'); @@ -73,16 +81,14 @@ describe('MessageReactions', () => { }); it('should render an emoji for each type of reaction', async () => { - const reaction_groups = { - haha: { count: 2 }, - love: { count: 5 }, - }; - - const { container } = renderComponent({ reaction_groups }); + const { container, getAllByTestId } = renderComponent({ + reaction_groups: { + haha: fromPartial({ count: 2 }), + love: fromPartial({ count: 5 }), + }, + }); - const listItems = container.querySelectorAll( - '.str-chat__message-reactions__list-item', - ); + const listItems = getAllByTestId('message-reactions-list-item'); expect(listItems).toHaveLength(2); const results = await axe(container); @@ -90,22 +96,18 @@ describe('MessageReactions', () => { }); it('should handle custom reaction options', async () => { - const reaction_groups = { - banana: { count: 1 }, - cowboy: { count: 2 }, - }; - - const { container } = renderComponent({ - reaction_groups, + const { container, getAllByTestId } = renderComponent({ + reaction_groups: { + banana: fromPartial({ count: 1 }), + cowboy: fromPartial({ count: 2 }), + }, reactionOptions: [ { Component: () => <>🍌, type: 'banana' }, { Component: () => <>🤠, type: 'cowboy' }, ], }); - const listItems = container.querySelectorAll( - '.str-chat__message-reactions__list-item', - ); + const listItems = getAllByTestId('message-reactions-list-item'); expect(listItems).toHaveLength(2); const results = await axe(container); @@ -113,26 +115,25 @@ describe('MessageReactions', () => { }); it('should order reactions by first reaction timestamp by default', () => { - const { container } = renderComponent({ + const { getAllByTestId } = renderComponent({ reaction_groups: { - haha: { count: 2, first_reaction_at: new Date().toISOString() }, - like: { + haha: fromPartial({ + count: 2, + first_reaction_at: new Date().toISOString(), + }), + like: fromPartial({ count: 8, first_reaction_at: new Date(Date.now() + 60_000).toISOString(), - }, - love: { + }), + love: fromPartial({ count: 5, first_reaction_at: new Date(Date.now() + 120_000).toISOString(), - }, + }), }, }); - const listItems = container.querySelectorAll( - '.str-chat__message-reactions__list-item', - ); - const icons = Array.from(listItems).map( - (item) => - item.querySelector('.str-chat__message-reactions__list-item-icon')?.textContent, + const icons = getAllByTestId('message-reactions-list-item-icon').map( + (el) => el.textContent, ); // haha (😂) should come first, then like (👍), then love (❤️) @@ -142,21 +143,17 @@ describe('MessageReactions', () => { }); it('should use custom comparator if provided', () => { - const { container } = renderComponent({ + const { getAllByTestId } = renderComponent({ reaction_groups: { - haha: { count: 2 }, - like: { count: 8 }, - love: { count: 5 }, + haha: fromPartial({ count: 2 }), + like: fromPartial({ count: 8 }), + love: fromPartial({ count: 5 }), }, sortReactions: (a, b) => b.reactionCount - a.reactionCount, }); - const listItems = container.querySelectorAll( - '.str-chat__message-reactions__list-item', - ); - const icons = Array.from(listItems).map( - (item) => - item.querySelector('.str-chat__message-reactions__list-item-icon')?.textContent, + const icons = getAllByTestId('message-reactions-list-item-icon').map( + (el) => el.textContent, ); // like (8) > love (5) > haha (2) diff --git a/src/components/Reactions/__tests__/MessageReactionsDetail.test.tsx b/src/components/Reactions/__tests__/MessageReactionsDetail.test.tsx index 68e64e298..6c94de33c 100644 --- a/src/components/Reactions/__tests__/MessageReactionsDetail.test.tsx +++ b/src/components/Reactions/__tests__/MessageReactionsDetail.test.tsx @@ -1,9 +1,11 @@ import React from 'react'; -import { render, waitFor } from '@testing-library/react'; - +import { fireEvent, render, waitFor } from '@testing-library/react'; import { axe } from '../../../../axe-helper'; -import { MessageReactionsDetail } from '../MessageReactionsDetail'; +import { + MessageReactionsDetail, + type MessageReactionsDetailProps, +} from '../MessageReactionsDetail'; import { MessageProvider } from '../../../context/MessageContext'; import { @@ -11,15 +13,20 @@ import { generateUser, getTestClient, mockChatContext, - mockComponentContext, mockMessageContext, } from '../../../mock-builders'; -import { ChatProvider, ComponentProvider, DialogManagerProvider } from '../../../context'; -import { defaultReactionOptions } from '../reactionOptions'; +import { ChatProvider, DialogManagerProvider, WithComponents } from '../../../context'; +import type { ComponentContextValue } from '../../../context/ComponentContext'; +import { defaultReactionOptions, type ReactionOptions } from '../reactionOptions'; import { useProcessReactions } from '../hooks/useProcessReactions'; -const generateReactionsFromReactionGroups = (reactionGroups: any) => - Object.entries(reactionGroups).flatMap(([type, { count }]: any) => +import type { ReactionGroupResponse, ReactionResponse } from 'stream-chat'; +import type { ReactionsComparator } from '../types'; + +const generateReactionsFromReactionGroups = ( + reactionGroups: Record>, +) => + Object.entries(reactionGroups).flatMap(([type, { count }]) => generateReactions(count, (i) => ({ type, user: generateUser({ id: `mark-${i}`, name: `Mark Number ${i}` }), @@ -34,12 +41,21 @@ const MessageReactionsDetailWrapper = ({ handleFetchReactions, reaction_groups, reactions, - sortReactionDetails, sortReactions, ...rest -}: any) => { +}: { + handleFetchReactions?: MessageReactionsDetailProps['handleFetchReactions']; + reaction_groups?: Record>; + reactions?: ReactionResponse[]; + sortReactions?: ReactionsComparator; +} & Partial< + Omit< + MessageReactionsDetailProps, + 'handleFetchReactions' | 'reactions' | 'totalReactionCount' + > +>) => { const { existingReactions, totalReactionCount } = useProcessReactions({ - reaction_groups, + reaction_groups: reaction_groups as Record, reactions, sortReactions, }); @@ -51,7 +67,6 @@ const MessageReactionsDetailWrapper = ({ selectedReactionType={ rest.selectedReactionType ?? existingReactions[0]?.reactionType ?? null } - sortReactionDetails={sortReactionDetails} totalReactionCount={totalReactionCount} {...rest} /> @@ -60,20 +75,23 @@ const MessageReactionsDetailWrapper = ({ const chatClient = getTestClient(); -const renderComponent = ({ handleFetchReactions, ...props }: any) => +const renderComponent = ({ + handleFetchReactions, + ...props +}: { + handleFetchReactions?: MessageReactionsDetailProps['handleFetchReactions']; +} & Record) => render( - + - + , ); @@ -94,7 +112,7 @@ describe('MessageReactionsDetail', () => { love: { count: 5 }, }; const reactions = generateReactionsFromReactionGroups(reactionGroups); - const fetchReactions = vi.fn((type) => + const fetchReactions = vi.fn((type: string) => Promise.resolve(reactions.filter((r) => r.type === type)), ); @@ -115,7 +133,7 @@ describe('MessageReactionsDetail', () => { love: { count: 5 }, }; const reactions = generateReactionsFromReactionGroups(reactionGroups); - const fetchReactions = vi.fn((type) => + const fetchReactions = vi.fn((type: string) => Promise.resolve(reactions.filter((r) => r.type === type)), ); @@ -137,16 +155,14 @@ describe('MessageReactionsDetail', () => { love: { count: 5 }, }; const reactions = generateReactionsFromReactionGroups(reactionGroups); - const fetchReactions = vi.fn((type) => + const fetchReactions = vi.fn((type: string) => Promise.resolve(reactions.filter((r) => r.type === type)), ); const { getAllByTestId, rerender } = render( - + { selectedReactionType='haha' /> - + , ); @@ -167,9 +183,7 @@ describe('MessageReactionsDetail', () => { rerender( - + { selectedReactionType='love' /> - + , ); @@ -218,7 +232,6 @@ describe('MessageReactionsDetail', () => { reaction_groups: reactionGroups, reactions, selectedReactionType: 'haha', - sortReactionDetails: (a, b) => -a.user.name.localeCompare(b.user.name), }); await waitFor(() => { @@ -269,4 +282,134 @@ describe('MessageReactionsDetail', () => { ); }); }); + + it('should always display reaction count for each reaction type', () => { + const reactionGroups = { + haha: { count: 1 }, + love: { count: 3 }, + }; + const reactions = generateReactionsFromReactionGroups(reactionGroups); + const fetchReactions = vi.fn(() => Promise.resolve([])); + + const { getAllByTestId } = renderComponent({ + handleFetchReactions: fetchReactions, + reaction_groups: reactionGroups, + reactions, + }); + + const counts = getAllByTestId('reaction-type-count'); + expect(counts).toHaveLength(2); + expect(counts[0]).toHaveTextContent('1'); + expect(counts[1]).toHaveTextContent('3'); + }); + + it('should render add emoji button in the reaction type list', () => { + const reactionGroups = { + love: { count: 2 }, + }; + const reactions = generateReactionsFromReactionGroups(reactionGroups); + const fetchReactions = vi.fn(() => Promise.resolve([])); + + const { getByTestId } = renderComponent({ + handleFetchReactions: fetchReactions, + reaction_groups: reactionGroups, + reactions, + }); + + expect(getByTestId('add-reaction-button')).toBeInTheDocument(); + }); + + it('should show extended reaction list when add emoji button is clicked', () => { + const reactionGroups = { + love: { count: 2 }, + }; + const reactions = generateReactionsFromReactionGroups(reactionGroups); + const fetchReactions = vi.fn(() => Promise.resolve([])); + + const extendedReactionOptions: ReactionOptions = { + extended: { + rocket: { Component: () => <>🚀, name: 'Rocket' }, + star: { Component: () => <>⭐, name: 'Star' }, + }, + quick: { + love: { Component: () => <>❤️, name: 'Heart' }, + }, + }; + + const { getByTestId, queryByTestId } = render( + + + + + + + + + , + ); + + fireEvent.click(getByTestId('add-reaction-button')); + + // Extended list should be visible + expect(getByTestId('reaction-selector-extended-list')).toBeInTheDocument(); + + // The detail panel should still be present + expect(getByTestId('message-reactions-detail')).toBeInTheDocument(); + + // The reaction type list should NOT be visible + expect(queryByTestId('reaction-type-list')).not.toBeInTheDocument(); + }); + + it('should use custom ReactionSelectorExtendedList from ComponentContext', () => { + const reactionGroups = { + love: { count: 2 }, + }; + const reactions = generateReactionsFromReactionGroups(reactionGroups); + const fetchReactions = vi.fn(() => Promise.resolve([])); + + const CustomExtendedList = vi.fn(() => ( +
Custom
+ )); + + const overrides: Partial = { + reactionOptions: { + extended: { + rocket: { Component: () => <>🚀, name: 'Rocket' }, + }, + quick: { + love: { Component: () => <>❤️, name: 'Heart' }, + }, + }, + ReactionSelectorExtendedList: CustomExtendedList, + }; + + const { getByTestId } = render( + + + + + + + + + , + ); + + fireEvent.click(getByTestId('add-reaction-button')); + + expect(getByTestId('custom-extended-list')).toBeInTheDocument(); + expect(CustomExtendedList).toHaveBeenCalled(); + }); }); diff --git a/src/components/Reactions/__tests__/ReactionSelector.test.tsx b/src/components/Reactions/__tests__/ReactionSelector.test.tsx index c7c6fc7f1..1dd325fdf 100644 --- a/src/components/Reactions/__tests__/ReactionSelector.test.tsx +++ b/src/components/Reactions/__tests__/ReactionSelector.test.tsx @@ -1,12 +1,11 @@ import React from 'react'; import { fireEvent, render } from '@testing-library/react'; +import { ReactionSelector, type ReactionSelectorProps } from '../ReactionSelector'; +import { defaultReactionOptions, type ReactionOptions } from '../reactionOptions'; -import { ReactionSelector } from '../ReactionSelector'; -import { defaultReactionOptions } from '../reactionOptions'; - -import { ComponentProvider } from '../../../context/ComponentContext'; import { MessageProvider } from '../../../context/MessageContext'; -import { DialogManagerProvider } from '../../../context'; +import { DialogManagerProvider, WithComponents } from '../../../context'; +import type { ComponentContextValue } from '../../../context/ComponentContext'; import { generateMessage, @@ -16,16 +15,71 @@ import { const handleReactionMock = vi.fn(); -const renderComponent = ({ reactionOptions, ...props }: any = {}) => +const defaultMessage = generateMessage(); + +const renderComponent = ({ + reactionOptions, + ReactionSelectorExtendedList, + ...props +}: Partial & { + reactionOptions?: ReactionOptions; + ReactionSelectorExtendedList?: ComponentContextValue['ReactionSelectorExtendedList']; +} = {}) => render( - - + - + + , + ); + +const extendedReactionOptions: ReactionOptions = { + extended: { + rocket: { Component: () => <>🚀, name: 'Rocket' }, + star: { Component: () => <>⭐, name: 'Star' }, + thumbsdown: { Component: () => <>👎, name: 'Thumbs down' }, + }, + quick: { + love: { Component: () => <>❤️, name: 'Heart' }, + }, +}; + +const renderExtendedList = ({ + componentOverrides = {}, + messageOverrides = {}, + ...props +}: Partial & { + componentOverrides?: Partial; + messageOverrides?: Record; + dialogId?: string; +} = {}) => + render( + + + + + + , ); @@ -43,17 +97,17 @@ describe('ReactionSelector', () => { }); it('should render each of reactionOptions if specified as an array (legacy format)', () => { - const reactionOptions = [ - { Component: vi.fn(() => test1), type: 'test1' }, - { Component: vi.fn(() => test2), type: 'test2' }, + const reactionOptions: ReactionOptions = [ + { Component: () => test1, type: 'test1' }, + { Component: () => test2, type: 'test2' }, ]; const { getAllByTestId } = renderComponent({ reactionOptions }); const buttons = getAllByTestId('select-reaction-button'); expect(buttons).toHaveLength(2); - reactionOptions.forEach((option) => { - expect(option.Component).toHaveBeenCalledTimes(1); + buttons.forEach((button, index) => { + expect(button).toHaveTextContent(reactionOptions[index].type); }); }); @@ -83,13 +137,12 @@ describe('ReactionSelector', () => { }); it('should render the add button for extended reactions', () => { - const { container } = renderComponent(); - const addButton = container.querySelector('.str-chat__reaction-selector__add-button'); - expect(addButton).toBeInTheDocument(); + const { getByTestId } = renderComponent(); + expect(getByTestId('reaction-selector-add-button')).toBeInTheDocument(); }); it('should show extended reaction list when add button is clicked', () => { - const reactionOptions = { + const reactionOptions: ReactionOptions = { extended: { rocket: { Component: () => <>🚀, name: 'Rocket' }, star: { Component: () => <>⭐, name: 'Star' }, @@ -98,19 +151,14 @@ describe('ReactionSelector', () => { love: { Component: () => <>❤️, name: 'Heart' }, }, }; - const { container } = renderComponent({ reactionOptions }); + const { getByTestId, queryByTestId } = renderComponent({ reactionOptions }); - const addButton = container.querySelector('.str-chat__reaction-selector__add-button'); - fireEvent.click(addButton); + fireEvent.click(getByTestId('reaction-selector-add-button')); - const extendedList = container.querySelector( - '.str-chat__reaction-selector-extended-list', - ); - expect(extendedList).toBeInTheDocument(); + expect(getByTestId('reaction-selector-extended-list')).toBeInTheDocument(); // Quick list should be hidden when extended list is open - const quickList = container.querySelector('.str-chat__reaction-selector-list'); - expect(quickList).not.toBeInTheDocument(); + expect(queryByTestId('reaction-selector-list')).not.toBeInTheDocument(); }); it('should have correct data-text attribute on each button', () => { @@ -123,4 +171,137 @@ describe('ReactionSelector', () => { expect.arrayContaining(['haha', 'like', 'love', 'sad', 'wow', 'fire']), ); }); + + it('should use custom ReactionSelectorExtendedList from ComponentContext', () => { + const CustomExtendedList = vi.fn(() => ( +
Custom
+ )); + + const { getByTestId } = renderComponent({ + reactionOptions: extendedReactionOptions, + ReactionSelectorExtendedList: CustomExtendedList, + }); + + fireEvent.click(getByTestId('reaction-selector-add-button')); + + expect(getByTestId('custom-extended-list')).toBeInTheDocument(); + expect(CustomExtendedList).toHaveBeenCalled(); + }); +}); + +describe('ReactionSelector.ExtendedList', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should render extended reaction buttons', () => { + const { getAllByTestId } = renderExtendedList(); + + const buttons = getAllByTestId('select-reaction-button'); + expect(buttons).toHaveLength(3); + + const dataTexts = buttons.map((btn) => btn.getAttribute('data-text')); + expect(dataTexts).toEqual(expect.arrayContaining(['rocket', 'star', 'thumbsdown'])); + }); + + it('should render null when reactionOptions is a legacy array', () => { + const legacyOptions: ReactionOptions = [{ Component: () => <>❤️, type: 'love' }]; + + const { queryByTestId } = renderExtendedList({ + componentOverrides: { reactionOptions: legacyOptions }, + }); + + expect(queryByTestId('reaction-selector-extended-list')).not.toBeInTheDocument(); + }); + + it('should render null when reactionOptions has no extended field', () => { + const quickOnly: ReactionOptions = { + quick: { + love: { Component: () => <>❤️, name: 'Heart' }, + }, + }; + + const { queryByTestId } = renderExtendedList({ + componentOverrides: { reactionOptions: quickOnly }, + }); + + expect(queryByTestId('reaction-selector-extended-list')).not.toBeInTheDocument(); + }); + + it('should mark own reactions with aria-pressed', () => { + const ownReaction = generateReaction({ type: 'rocket' }); + const { getAllByTestId } = renderExtendedList({ + own_reactions: [ownReaction], + }); + + const buttons = getAllByTestId('select-reaction-button'); + const rocketButton = buttons.find( + (btn) => btn.getAttribute('data-text') === 'rocket', + ); + const starButton = buttons.find((btn) => btn.getAttribute('data-text') === 'star'); + + expect(rocketButton).toHaveAttribute('aria-pressed', 'true'); + expect(starButton).toHaveAttribute('aria-pressed', 'false'); + }); + + it('should call prop handleReaction over context handleReaction', () => { + const propHandleReaction = vi.fn(); + const { getAllByTestId } = renderExtendedList({ + handleReaction: propHandleReaction, + }); + + const buttons = getAllByTestId('select-reaction-button'); + fireEvent.click(buttons[0]); + + expect(propHandleReaction).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + ); + }); + + it('should fall back to context handleReaction when prop is not provided', () => { + const contextHandleReaction = vi.fn(); + const { getAllByTestId } = renderExtendedList({ + messageOverrides: { handleReaction: contextHandleReaction }, + }); + + const buttons = getAllByTestId('select-reaction-button'); + fireEvent.click(buttons[0]); + + expect(contextHandleReaction).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + ); + }); + + it('should render correct aria-label using reaction name', () => { + const { getAllByTestId } = renderExtendedList(); + + const buttons = getAllByTestId('select-reaction-button'); + const rocketButton = buttons.find( + (btn) => btn.getAttribute('data-text') === 'rocket', + ); + + expect(rocketButton).toHaveAttribute('aria-label', 'Select Reaction: Rocket'); + }); + + it('should use reaction type as aria-label fallback when name is missing', () => { + const optionsWithoutName: ReactionOptions = { + extended: { + custom: { Component: () => <>🎯 }, + }, + quick: { + love: { Component: () => <>❤️, name: 'Heart' }, + }, + }; + + const { getByTestId } = renderExtendedList({ + componentOverrides: { reactionOptions: optionsWithoutName }, + }); + + expect(getByTestId('select-reaction-button')).toHaveAttribute( + 'aria-label', + 'Select Reaction: custom', + ); + }); }); diff --git a/src/components/Reactions/styling/MessageReactionsDetail.scss b/src/components/Reactions/styling/MessageReactionsDetail.scss index 809dcdd07..e35eaef5a 100644 --- a/src/components/Reactions/styling/MessageReactionsDetail.scss +++ b/src/components/Reactions/styling/MessageReactionsDetail.scss @@ -14,24 +14,21 @@ max-width: 256px; min-width: min(90vw, 256px); - &::after { - content: ''; - position: absolute; - width: 100%; - bottom: 0; - inset-inline-start: 0; - height: var(--size-12); - border-end-end-radius: inherit; - border-end-start-radius: inherit; - background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 5%, rgba(0, 0, 0, 0.1) 130%); + &:has(.str-chat__reaction-selector-extended-list) { + @include common.clipping-fade; + @include utils.scrollable-y; + + padding: 0; + display: block; + scrollbar-width: none; + max-height: 320px; + max-width: unset; + min-width: unset; } font-family: var(--typography-font-family-sans); - box-shadow: - 0 0 0 1px rgba(0, 0, 0, 0.05), - 0 4px 8px 0 rgba(0, 0, 0, 0.14), - 0 12px 24px 0 rgba(0, 0, 0, 0.1); + box-shadow: var(--light-elevation-3); padding-block-start: var(--spacing-xxs); @@ -56,7 +53,7 @@ .str-chat__message-reactions-detail__reaction-type-list { list-style: none; margin: 0; - padding-inline: var(--spacing-xs); + padding-inline: var(--spacing-md); padding-block: var(--spacing-xs); display: flex; flex-wrap: wrap; @@ -68,12 +65,24 @@ .str-chat__message-reactions-detail__reaction-type-list-item-button { @include common.reaction-button; box-shadow: unset; + min-width: var(--size-48); .str-chat__message-reactions-detail__reaction-type-list-item-icon { font-family: system-ui; font-size: var(--font-size-size-17); font-style: normal; line-height: var(--typography-line-height-normal); + + .str-chat__icon { + width: var(--icon-size-sm); + height: var(--icon-size-sm); + } + + &:has(.str-chat__icon) { + display: flex; + align-items: center; + justify-content: center; + } } .str-chat__message-reactions-detail__reaction-type-list-item-count { @@ -84,19 +93,21 @@ } } + .str-chat__message-reactions-detail__user-list-container { + position: relative; + border-radius: inherit; + @include common.clipping-fade; + &::before { + display: none; + } + } + .str-chat__message-reactions-detail__user-list { @include utils.scrollable-y; scrollbar-width: none; position: relative; padding-block-end: var(--spacing-xxs); - max-height: 100px; - - &:has( - .str-chat__message-reactions-detail__user-list-item - .str-chat__message-reactions-detail__user-list-item-button:nth-child(-n + 3) - ) { - max-height: 106px; - } + max-height: 180px; .str-chat__message-reactions-detail__skeleton-item { padding-block: var(--spacing-xxs); diff --git a/src/components/Reactions/styling/ReactionSelector.scss b/src/components/Reactions/styling/ReactionSelector.scss index 499764caa..5e6c00b2c 100644 --- a/src/components/Reactions/styling/ReactionSelector.scss +++ b/src/components/Reactions/styling/ReactionSelector.scss @@ -1,4 +1,5 @@ -@use '../../../styling/utils'; +@use '../../../styling/utils' as utils; +@use './common' as common; .str-chat__reaction-selector { display: flex; @@ -9,10 +10,9 @@ gap: var(--spacing-xs); border-radius: var(--radius-4xl, 32px); - border: 1px solid var(--border-core-surface-subtle, #e2e6ea); background: var(--background-core-elevation-2, #fff); - box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.16); + box-shadow: var(--light-elevation-3); &:has(.str-chat__reaction-selector-extended-list) { padding: 0; @@ -20,7 +20,9 @@ @include utils.scrollable-y; scrollbar-width: none; border-radius: var(--radius-lg); - max-height: 250px; + max-height: 320px; + + @include common.clipping-fade; } .str-chat__reaction-selector__add-button { @@ -33,29 +35,6 @@ } } - .str-chat__reaction-selector-extended-list { - display: grid; - grid-template-columns: repeat(7, 1fr); - height: 100%; - padding-block: var(--spacing-md); - padding-inline: var(--spacing-sm); - - .str-chat__reaction-selector-extended-list__button { - .str-chat__reaction-icon { - height: var(--emoji-md); - width: var(--emoji-md); - font-size: var(--emoji-md); - letter-spacing: var(--typography-letter-spacing-default); - font-style: normal; - 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); diff --git a/src/components/Reactions/styling/ReactionSelectorExtendedList.scss b/src/components/Reactions/styling/ReactionSelectorExtendedList.scss new file mode 100644 index 000000000..fb0ca14a5 --- /dev/null +++ b/src/components/Reactions/styling/ReactionSelectorExtendedList.scss @@ -0,0 +1,22 @@ +.str-chat__reaction-selector-extended-list { + display: grid; + grid-template-columns: repeat(7, 1fr); + height: 100%; + padding-block: var(--spacing-md); + padding-inline: var(--spacing-sm); + + .str-chat__reaction-selector-extended-list__button { + .str-chat__reaction-icon { + height: var(--emoji-md); + width: var(--emoji-md); + font-size: var(--emoji-md); + letter-spacing: var(--typography-letter-spacing-default); + font-style: normal; + line-height: 0; + font-family: system-ui; + display: flex; + justify-content: center; + align-items: center; + } + } +} diff --git a/src/components/Reactions/styling/common.scss b/src/components/Reactions/styling/common.scss index 617f178c7..4b5b9ca05 100644 --- a/src/components/Reactions/styling/common.scss +++ b/src/components/Reactions/styling/common.scss @@ -46,3 +46,38 @@ } } } + +@mixin clipping-fade() { + &::before, + &::after { + content: ''; + position: absolute; + width: 100%; + inset-inline-start: 0; + height: var(--size-16); + } + + &::after { + bottom: 0; + border-end-end-radius: inherit; + border-end-start-radius: inherit; + background: linear-gradient( + to bottom, + transparent 5%, + var(--background-core-elevation-0) 95% + ); + } + + &::before { + // TODO: figure out a better way (z-index isn't optimal) + z-index: 1; + top: 0; + border-start-end-radius: inherit; + border-start-start-radius: inherit; + background: linear-gradient( + to top, + transparent 5%, + var(--background-core-elevation-0) 95% + ); + } +} diff --git a/src/components/Reactions/styling/index.scss b/src/components/Reactions/styling/index.scss index 364ea1d65..4490d7d54 100644 --- a/src/components/Reactions/styling/index.scss +++ b/src/components/Reactions/styling/index.scss @@ -1,3 +1,4 @@ @use 'ReactionSelector'; +@use 'ReactionSelectorExtendedList'; @use 'MessageReactions'; @use 'MessageReactionsDetail'; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index 8effb1042..322197a65 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -1,4 +1,4 @@ -import type { PropsWithChildren } from 'react'; +import type { ComponentProps, PropsWithChildren } from 'react'; import React, { useContext } from 'react'; import { @@ -43,6 +43,7 @@ import { type PollOptionSelectorProps, type QuotedMessagePreviewProps, type ReactionOptions, + type ReactionSelector, type ReactionSelectorProps, type RecordingPermissionDeniedNotificationProps, type ReminderNotificationProps, @@ -211,6 +212,9 @@ export type ComponentContextValue = { reactionOptions?: ReactionOptions; /** Custom UI component to display the reaction selector, defaults to and accepts same props as: [ReactionSelector](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Reactions/ReactionSelector.tsx) */ ReactionSelector?: React.ForwardRefExoticComponent; + ReactionSelectorExtendedList?: React.ComponentType< + ComponentProps<(typeof ReactionSelector)['ExtendedList']> + >; /** Custom UI component to display the list of reactions on a message, defaults to and accepts same props as: [MessageReactions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Reactions/MessageReactions.tsx) */ MessageReactions?: React.ComponentType; /** Custom UI component to display the reactions modal, defaults to and accepts same props as: [MessageReactionsDetail](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Reactions/MessageReactionsDetail.tsx) */ diff --git a/src/i18n/de.json b/src/i18n/de.json index 05f9b4225..ff81ac8e6 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -38,6 +38,7 @@ "📍Shared location": "📍Geteilter Standort", "Add a comment": "Einen Kommentar hinzufügen", "Add an option": "Eine Option hinzufügen", + "Add reaction": "Reaktion hinzufügen", "All results loaded": "Alle Ergebnisse geladen", "Allow access to camera": "Zugriff auf Kamera erlauben", "Allow access to microphone": "Zugriff auf Mikrofon erlauben", diff --git a/src/i18n/en.json b/src/i18n/en.json index 00b2660f0..36a4fed93 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -38,6 +38,7 @@ "📍Shared location": "📍Shared location", "Add a comment": "Add a Comment", "Add an option": "Add an Option", + "Add reaction": "Add reaction", "All results loaded": "All results loaded", "Allow access to camera": "Allow access to camera", "Allow access to microphone": "Allow access to microphone", diff --git a/src/i18n/es.json b/src/i18n/es.json index 18c6a1deb..7bab77976 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -46,6 +46,7 @@ "📍Shared location": "📍Ubicación compartida", "Add a comment": "Agregar un comentario", "Add an option": "Agregar una opción", + "Add reaction": "Añadir reacción", "All results loaded": "Todos los resultados cargados", "Allow access to camera": "Permitir acceso a la cámara", "Allow access to microphone": "Permitir acceso al micrófono", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 1aefa165a..a4fe0fa7b 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -46,6 +46,7 @@ "📍Shared location": "📍Emplacement partagé", "Add a comment": "Ajouter un commentaire", "Add an option": "Ajouter une option", + "Add reaction": "Ajouter une réaction", "All results loaded": "Tous les résultats sont chargés", "Allow access to camera": "Autoriser l'accès à la caméra", "Allow access to microphone": "Autoriser l'accès au microphone", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index dfedcdd9b..4b896a4a1 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -38,6 +38,7 @@ "📍Shared location": "📍साझा किया गया स्थान", "Add a comment": "एक टिप्पणी जोड़ें", "Add an option": "एक विकल्प जोड़ें", + "Add reaction": "प्रतिक्रिया जोड़ें", "All results loaded": "सभी परिणाम लोड हो गए", "Allow access to camera": "कैमरा तक पहुँच दें", "Allow access to microphone": "माइक्रोफ़ोन तक पहुँच दें", diff --git a/src/i18n/it.json b/src/i18n/it.json index 2d6b1d9ce..c7017103a 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -46,6 +46,7 @@ "📍Shared location": "📍Posizione condivisa", "Add a comment": "Aggiungi un commento", "Add an option": "Aggiungi un'opzione", + "Add reaction": "Aggiungi reazione", "All results loaded": "Tutti i risultati caricati", "Allow access to camera": "Consenti l'accesso alla fotocamera", "Allow access to microphone": "Consenti l'accesso al microfono", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 7c1b21166..32b201084 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -37,6 +37,7 @@ "📍Shared location": "📍共有された位置情報", "Add a comment": "コメントを追加", "Add an option": "オプションを追加", + "Add reaction": "リアクションを追加", "All results loaded": "すべての結果が読み込まれました", "Allow access to camera": "カメラへのアクセスを許可する", "Allow access to microphone": "マイクロフォンへのアクセスを許可する", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 601798b16..ed8a861c5 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -37,6 +37,7 @@ "📍Shared location": "📍공유된 위치", "Add a comment": "댓글 추가", "Add an option": "옵션 추가", + "Add reaction": "반응 추가", "All results loaded": "모든 결과가 로드되었습니다", "Allow access to camera": "카메라에 대한 액세스 허용", "Allow access to microphone": "마이크로폰에 대한 액세스 허용", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 0bc74d805..b40d30e0a 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -38,6 +38,7 @@ "📍Shared location": "📍Gedeelde locatie", "Add a comment": "Voeg een opmerking toe", "Add an option": "Voeg een optie toe", + "Add reaction": "Reactie toevoegen", "All results loaded": "Alle resultaten geladen", "Allow access to camera": "Toegang tot camera toestaan", "Allow access to microphone": "Toegang tot microfoon toestaan", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index e47de7aeb..d4927b696 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -46,6 +46,7 @@ "📍Shared location": "📍Localização compartilhada", "Add a comment": "Adicionar um comentário", "Add an option": "Adicionar uma opção", + "Add reaction": "Adicionar reação", "All results loaded": "Todos os resultados carregados", "Allow access to camera": "Permitir acesso à câmera", "Allow access to microphone": "Permitir acesso ao microfone", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 3f9569b1a..e0902f0b0 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -55,6 +55,7 @@ "📍Shared location": "📍Общее местоположение", "Add a comment": "Добавить комментарий", "Add an option": "Добавить вариант", + "Add reaction": "Добавить реакцию", "All results loaded": "Все результаты загружены", "Allow access to camera": "Разрешить доступ к камере", "Allow access to microphone": "Разрешить доступ к микрофону", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index 94db22366..90bd87c36 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -38,6 +38,7 @@ "📍Shared location": "📍Paylaşılan konum", "Add a comment": "Yorum ekle", "Add an option": "Bir seçenek ekle", + "Add reaction": "Tepki ekle", "All results loaded": "Tüm sonuçlar yüklendi", "Allow access to camera": "Kameraya erişime izin ver", "Allow access to microphone": "Mikrofona erişime izin ver",