From 49d8c8722ff197dfc5983d780d38c2a6333dca56 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Fri, 20 Feb 2026 20:07:53 +0100 Subject: [PATCH 1/2] Initial commit --- .../Avatar/styling/AvatarStack.scss | 2 +- .../Dialog/hooks/usePopoverPosition.ts | 2 +- .../Dialog/service/DialogAnchor.tsx | 45 +++- src/components/Message/MessageSimple.tsx | 6 +- .../Message/hooks/useReactionsFetcher.ts | 5 +- src/components/Message/styling/Message.scss | 5 +- src/components/Reactions/ReactionsList.tsx | 95 +++++++-- .../Reactions/ReactionsListModal.tsx | 197 ++++++++++-------- .../Reactions/hooks/useFetchReactions.ts | 15 +- .../Reactions/hooks/useProcessReactions.tsx | 22 +- .../Reactions/styling/ReactionList.scss | 84 ++------ .../Reactions/styling/ReactionsListModal.scss | 175 ++++++++++------ src/components/Reactions/styling/common.scss | 46 ++++ src/i18n/de.json | 3 + src/i18n/en.json | 3 + src/i18n/es.json | 4 + src/i18n/fr.json | 4 + src/i18n/hi.json | 3 + src/i18n/it.json | 4 + src/i18n/ja.json | 2 + src/i18n/ko.json | 2 + src/i18n/nl.json | 3 + src/i18n/pt.json | 4 + src/i18n/ru.json | 5 + src/i18n/tr.json | 3 + 25 files changed, 493 insertions(+), 246 deletions(-) create mode 100644 src/components/Reactions/styling/common.scss diff --git a/src/components/Avatar/styling/AvatarStack.scss b/src/components/Avatar/styling/AvatarStack.scss index 2902bdbff..203c62a3b 100644 --- a/src/components/Avatar/styling/AvatarStack.scss +++ b/src/components/Avatar/styling/AvatarStack.scss @@ -49,6 +49,6 @@ background: var(--badge-bg-default); box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.14); line-height: 1; - z-index: 1; + position: relative; } } diff --git a/src/components/Dialog/hooks/usePopoverPosition.ts b/src/components/Dialog/hooks/usePopoverPosition.ts index c15256069..190794158 100644 --- a/src/components/Dialog/hooks/usePopoverPosition.ts +++ b/src/components/Dialog/hooks/usePopoverPosition.ts @@ -21,7 +21,7 @@ function autoMiddlewareFor(p: PopperLikePlacement) { return autoPlacement({ alignment }); } -type OffsetOpt = +export type OffsetOpt = | number | { mainAxis?: number; crossAxis?: number; alignmentAxis?: number } | [crossAxis: number, mainAxis: number]; // keep your tuple compat diff --git a/src/components/Dialog/service/DialogAnchor.tsx b/src/components/Dialog/service/DialogAnchor.tsx index dadfc1408..a88ff95ff 100644 --- a/src/components/Dialog/service/DialogAnchor.tsx +++ b/src/components/Dialog/service/DialogAnchor.tsx @@ -4,8 +4,9 @@ import React, { useEffect, useRef, useState } from 'react'; import { FocusScope } from '@react-aria/focus'; import { DialogPortalEntry } from './DialogPortal'; import { useDialog, useDialogIsOpen } from '../hooks'; -import { usePopoverPosition } from '../hooks/usePopoverPosition'; +import { type OffsetOpt, usePopoverPosition } from '../hooks/usePopoverPosition'; import type { PopperLikePlacement } from '../hooks'; +import type { Placement } from '@floating-ui/react'; export interface DialogAnchorOptions { open: boolean; @@ -13,22 +14,44 @@ export interface DialogAnchorOptions { referenceElement: HTMLElement | null; allowFlip?: boolean; updateKey?: unknown; + updatePositionOnContentResize?: boolean; + offset?: OffsetOpt; } export function useDialogAnchor({ allowFlip, + offset, open, placement, referenceElement, updateKey, + updatePositionOnContentResize = false, }: DialogAnchorOptions) { const [popperElement, setPopperElement] = useState(null); - const { refs, strategy, update, x, y } = usePopoverPosition({ + // keeps track of the first "chosen" placement (after popperElement is set) to avoid popper "jumping" to a different placement when it updates and finds a better fit; resets when popperElement is unset (!open) + const [stabilisedChosenPlacement, setStabilisedChosenPlacement] = + useState(null); + + const { + placement: chosenPlacement, + refs, + strategy, + update, + x, + y, + } = usePopoverPosition({ allowFlip, freeze: true, - placement, + offset, + placement: stabilisedChosenPlacement ?? placement, }); + if (!stabilisedChosenPlacement && popperElement && placement !== chosenPlacement) { + setStabilisedChosenPlacement(chosenPlacement); + } else if (stabilisedChosenPlacement && !popperElement) { + setStabilisedChosenPlacement(null); + } + // Freeze reference when dialog opens so submenus (e.g. ContextMenu level 2+) stay aligned to the original anchor const frozenReferenceRef = useRef(null); if (open && referenceElement && !frozenReferenceRef.current) { @@ -57,6 +80,18 @@ export function useDialogAnchor({ } }, [open, placement, popperElement, update, updateKey, effectiveReference]); + useEffect(() => { + if (!popperElement || !updatePositionOnContentResize) return; + + const resizeObserver = new ResizeObserver(update); + + resizeObserver.observe(popperElement); + + return () => { + resizeObserver.disconnect(); + }; + }, [popperElement, update, updatePositionOnContentResize]); + if (popperElement && !open) { setPopperElement(null); } @@ -85,11 +120,13 @@ export const DialogAnchor = ({ dialogManagerId, focus = true, id, + offset, placement = 'auto', referenceElement = null, tabIndex, trapFocus, updateKey, + updatePositionOnContentResize, ...restDivProps }: DialogAnchorProps) => { const dialog = useDialog({ dialogManagerId, id }); @@ -97,10 +134,12 @@ export const DialogAnchor = ({ const { setPopperElement, styles } = useDialogAnchor({ allowFlip, + offset, open, placement, referenceElement, updateKey, + updatePositionOnContentResize, }); useEffect(() => { diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx index 1765b058a..f1e4048ff 100644 --- a/src/components/Message/MessageSimple.tsx +++ b/src/components/Message/MessageSimple.tsx @@ -219,9 +219,6 @@ const MessageSimpleWithContext = ({ onKeyUp={handleClick} > {!isDeleted && } -
- {hasReactions && } -
{message.deleted_at ? ( ) : ( @@ -241,6 +238,9 @@ const MessageSimpleWithContext = ({ )} +
+ {hasReactions && } +
)} diff --git a/src/components/Message/hooks/useReactionsFetcher.ts b/src/components/Message/hooks/useReactionsFetcher.ts index dcb9f69ac..588b798bf 100644 --- a/src/components/Message/hooks/useReactionsFetcher.ts +++ b/src/components/Message/hooks/useReactionsFetcher.ts @@ -1,4 +1,5 @@ import { useChatContext, useTranslationContext } from '../../../context'; +import { useStableCallback } from '../../../utils/useStableCallback'; import type { LocalMessage, ReactionResponse, @@ -22,7 +23,7 @@ export function useReactionsFetcher( const { t } = useTranslationContext('useReactionFetcher'); const { getErrorNotification, notify } = notifications; - return async (reactionType?: ReactionType, sort?: ReactionSort) => { + return useStableCallback(async (reactionType?: ReactionType, sort?: ReactionSort) => { try { return await fetchMessageReactions(client, message.id, reactionType, sort); } catch (e) { @@ -30,7 +31,7 @@ export function useReactionsFetcher( notify?.(errorMessage || t('Error fetching reactions'), 'error'); throw e; } - }; + }); } async function fetchMessageReactions( diff --git a/src/components/Message/styling/Message.scss b/src/components/Message/styling/Message.scss index 03d45f1f2..120b2ca4c 100644 --- a/src/components/Message/styling/Message.scss +++ b/src/components/Message/styling/Message.scss @@ -376,7 +376,9 @@ align-self: end; } - &:not(.str-chat__message--with-avatar) .str-chat__avatar { + &:not(.str-chat__message--with-avatar) + .str-chat__avatar:has(~ .str-chat__message-inner) { + // hide only avatars next to message bubble, not the rest of them down the tree display: none; } @@ -394,7 +396,6 @@ .str-chat__message-reactions-host { display: flex; grid-area: reactions; - z-index: 1; &:has(.str-chat__message-reactions--top) { margin-bottom: calc(var(--spacing-xxs) * -1); diff --git a/src/components/Reactions/ReactionsList.tsx b/src/components/Reactions/ReactionsList.tsx index 54d401755..6f40dae86 100644 --- a/src/components/Reactions/ReactionsList.tsx +++ b/src/components/Reactions/ReactionsList.tsx @@ -1,11 +1,20 @@ -import React, { type ComponentPropsWithoutRef, useState } from 'react'; +import React, { + type ComponentPropsWithoutRef, + type ComponentRef, + useMemo, + useRef, + useState, +} from 'react'; import clsx from 'clsx'; -import type { ReactionsListModalProps } from './ReactionsListModal'; import { ReactionsListModal as DefaultReactionsListModal } from './ReactionsListModal'; import { useProcessReactions } from './hooks/useProcessReactions'; import type { MessageContextValue } from '../../context'; -import { useComponentContext, useTranslationContext } from '../../context'; +import { + useComponentContext, + useMessageContext, + useTranslationContext, +} from '../../context'; import { MAX_MESSAGE_REACTIONS_TO_FETCH } from '../Message/hooks'; @@ -16,6 +25,7 @@ import type { ReactionsComparator, ReactionType, } from './types'; +import { DialogAnchor, useDialogOnNearestManager } from '../Dialog'; export type ReactionsListProps = Partial< Pick @@ -53,6 +63,9 @@ export type ReactionsListProps = Partial< visualStyle?: 'clustered' | 'segmented' | null; }; +/** + * Renders a button if `buttonIf` is true, otherwise renders a fragment. No props but children are passed to fragment, but all props are passed to button if it's rendered. + */ const FragmentOrButton = ({ buttonIf: renderButton = false, children, @@ -77,22 +90,47 @@ const UnMemoizedReactionsList = (props: ReactionsListProps) => { ...rest } = props; - const { existingReactions, hasReactions, totalReactionCount } = + const { existingReactions, hasReactions, totalReactionCount, uniqueReactionTypeCount } = useProcessReactions(rest); const [selectedReactionType, setSelectedReactionType] = useState( null, ); const { t } = useTranslationContext('ReactionsList'); const { ReactionsListModal = DefaultReactionsListModal } = useComponentContext(); + const { isMyMessage, message } = useMessageContext('ReactionsList'); + + const divRef = useRef>(null); + const dialogId = `message-reactions-detail-${message.id}`; + const { dialog, dialogManager } = useDialogOnNearestManager({ id: dialogId }); - const handleReactionButtonClick = (reactionType: ReactionType) => { + const handleReactionButtonClick = (reactionType: ReactionType | null) => { if (totalReactionCount > MAX_MESSAGE_REACTIONS_TO_FETCH) { return; } setSelectedReactionType(reactionType); + + dialog.open(); }; + /** + * In segmented style with top position we show max 4 reactions and a + * count of the rest, so we need to cap the existing reactions to display + * at 4 and calculate the count of the rest. + */ + const cappedExistingReactions = useMemo(() => { + if (visualStyle !== 'segmented' || verticalPosition !== 'top') return null; + + const sliced = existingReactions.slice(0, 4); + return { + reactionCountToDisplay: sliced.reduce( + (accumulatedCount, { reactionCount }) => accumulatedCount + reactionCount, + 0, + ), + reactionsToDisplay: sliced, + }; + }, [existingReactions, verticalPosition, visualStyle]); + if (!hasReactions) return null; return ( @@ -106,17 +144,18 @@ const UnMemoizedReactionsList = (props: ReactionsListProps) => { [`str-chat__message-reactions--${visualStyle}`]: typeof visualStyle === 'string', })} + ref={divRef} role='figure' > - setSelectedReactionType(existingReactions[0]?.reactionType ?? null) + handleReactionButtonClick(existingReactions[0]?.reactionType ?? null) } >
    - {existingReactions.map( + {(cappedExistingReactions?.reactionsToDisplay ?? existingReactions).map( ({ EmojiComponent, reactionCount, reactionType }) => EmojiComponent && (
  • { className='str-chat__message-reactions__list-item-button' onClick={() => handleReactionButtonClick(reactionType)} > - + - {visualStyle === 'segmented' && ( + {visualStyle === 'segmented' && reactionCount > 1 && ( {reactionCount} @@ -143,6 +182,22 @@ const UnMemoizedReactionsList = (props: ReactionsListProps) => {
  • ), )} + {uniqueReactionTypeCount > 4 && cappedExistingReactions && ( +
  • + +
  • + )}
{visualStyle === 'clustered' && ( @@ -151,19 +206,25 @@ const UnMemoizedReactionsList = (props: ReactionsListProps) => { )}
- {selectedReactionType !== null && ( + + setSelectedReactionType(null)} - onSelectedReactionTypeChange={ - setSelectedReactionType as ReactionsListModalProps['onSelectedReactionTypeChange'] - } - open={selectedReactionType !== null} + onSelectedReactionTypeChange={setSelectedReactionType} reactions={existingReactions} selectedReactionType={selectedReactionType} sortReactionDetails={sortReactionDetails} + totalReactionCount={totalReactionCount} /> - )} + ); }; diff --git a/src/components/Reactions/ReactionsListModal.tsx b/src/components/Reactions/ReactionsListModal.tsx index e69ae75c2..fbce34f7f 100644 --- a/src/components/Reactions/ReactionsListModal.tsx +++ b/src/components/Reactions/ReactionsListModal.tsx @@ -1,29 +1,34 @@ import React, { useMemo } from 'react'; -import clsx from 'clsx'; import type { ReactionDetailsComparator, ReactionSummary, ReactionType } from './types'; -import type { ModalProps } from '../Modal'; -import { GlobalModal } from '../Modal'; import { useFetchReactions } from './hooks/useFetchReactions'; -import { LoadingIndicator } from '../Loading'; -import { Avatar } from '../Avatar'; -import type { MessageContextValue } from '../../context'; -import { useComponentContext, useMessageContext } from '../../context'; +import { LoadingIndicator as DefaultLoadingIndicator } from '../Loading'; +import { Avatar as DefaultAvatar } from '../Avatar'; +import { + useChatContext, + useComponentContext, + useMessageContext, + useTranslationContext, +} from '../../context'; import type { ReactionSort } from 'stream-chat'; +import type { MessageContextValue } from '../../context'; -export type ReactionsListModalProps = ModalProps & - Partial> & { - reactions: ReactionSummary[]; - selectedReactionType: ReactionType; - onSelectedReactionTypeChange?: (reactionType: ReactionType) => void; - sort?: ReactionSort; - /** @deprecated use `sort` instead */ - sortReactionDetails?: ReactionDetailsComparator; - }; +export type ReactionsListModalProps = Partial< + Pick +> & { + reactions: ReactionSummary[]; + selectedReactionType: ReactionType | null; + onSelectedReactionTypeChange?: (reactionType: ReactionType | null) => void; + sort?: ReactionSort; + /** @deprecated use `sort` instead */ + sortReactionDetails?: ReactionDetailsComparator; + totalReactionCount?: number; +}; const defaultReactionDetailsSort = { created_at: -1 } as const; +// TODO: rename to MessageReactionsDetail export function ReactionsListModal({ handleFetchReactions, onSelectedReactionTypeChange, @@ -31,27 +36,34 @@ export function ReactionsListModal({ reactions, selectedReactionType, sortReactionDetails: propSortReactionDetails, - ...modalProps + totalReactionCount, }: ReactionsListModalProps) { - const { Modal = GlobalModal } = useComponentContext(); - const selectedReaction = reactions.find( - ({ reactionType }) => reactionType === selectedReactionType, - ); - const SelectedEmojiComponent = selectedReaction?.EmojiComponent ?? null; + const { client } = useChatContext(); + const { Avatar = DefaultAvatar, LoadingIndicator = DefaultLoadingIndicator } = + useComponentContext('ReactionsListModal'); + const { t } = useTranslationContext(); + const { + handleReaction: contextHandleReaction, reactionDetailsSort: contextReactionDetailsSort, sortReactionDetails: contextSortReactionDetails, } = useMessageContext('ReactionsListModal'); + const legacySortReactionDetails = propSortReactionDetails ?? contextSortReactionDetails; + const reactionDetailsSort = propReactionDetailsSort ?? contextReactionDetailsSort ?? defaultReactionDetailsSort; - const { isLoading: areReactionsLoading, reactions: reactionDetails } = - useFetchReactions({ - handleFetchReactions, - reactionType: selectedReactionType, - shouldFetch: modalProps.open, - sort: reactionDetailsSort, - }); + + const { + isLoading: areReactionsLoading, + reactions: reactionDetails, + refetch, + } = useFetchReactions({ + handleFetchReactions, + reactionType: selectedReactionType, + shouldFetch: true, + sort: reactionDetailsSort, + }); const reactionDetailsWithLegacyFallback = useMemo( () => @@ -62,75 +74,94 @@ export function ReactionsListModal({ ); return ( - -
-
+ {typeof totalReactionCount === 'number' && ( +
+ {t('{{ count }} reactions', { count: totalReactionCount })} +
+ )} +
+
    {reactions.map( ({ EmojiComponent, reactionCount, reactionType }) => EmojiComponent && ( -
    - onSelectedReactionTypeChange?.(reactionType as ReactionType) - } > - - - -   - - {reactionCount} - -
    + + ), )} -
- {SelectedEmojiComponent && ( -
- + +
+
+ {areReactionsLoading && ( +
+
)} -
- {areReactionsLoading ? ( - - ) : ( - reactionDetailsWithLegacyFallback.map(({ user }) => ( -
- + {reactionDetailsWithLegacyFallback.map(({ user }) => { + const belongsToCurrentUser = client.user?.id === user?.id; + return ( +
+ +
- {user?.name || user?.id} + {belongsToCurrentUser ? t('You') : user?.name || user?.id} + {belongsToCurrentUser && selectedReactionType && ( + + )}
- )) - )} -
+
+ ); + })}
- +
); } diff --git a/src/components/Reactions/hooks/useFetchReactions.ts b/src/components/Reactions/hooks/useFetchReactions.ts index b8effce08..6dc479bf4 100644 --- a/src/components/Reactions/hooks/useFetchReactions.ts +++ b/src/components/Reactions/hooks/useFetchReactions.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { ReactionResponse, ReactionSort } from 'stream-chat'; import type { MessageContextValue } from '../../../context'; import { useMessageContext } from '../../../context'; @@ -6,7 +6,7 @@ import { useMessageContext } from '../../../context'; import type { ReactionType } from '../types'; export interface FetchReactionsOptions { - reactionType: ReactionType; + reactionType: ReactionType | null; shouldFetch: boolean; handleFetchReactions?: MessageContextValue['handleFetchReactions']; sort?: ReactionSort; @@ -24,9 +24,10 @@ export function useFetchReactions(options: FetchReactionsOptions) { } = options; const [isLoading, setIsLoading] = useState(shouldFetch); const handleFetchReactions = propHandleFetchReactions ?? contextHandleFetchReactions; + const [refetchNonce, setRefetchNonce] = useState(null); useEffect(() => { - if (!shouldFetch) { + if (!shouldFetch || !reactionType) { return; } @@ -54,7 +55,11 @@ export function useFetchReactions(options: FetchReactionsOptions) { return () => { cancel = true; }; - }, [handleFetchReactions, reactionType, shouldFetch, sort]); + }, [handleFetchReactions, reactionType, shouldFetch, sort, refetchNonce]); - return { isLoading, reactions }; + const refetch = useCallback(() => { + setRefetchNonce(Math.random()); + }, []); + + return { isLoading, reactions, refetch }; } diff --git a/src/components/Reactions/hooks/useProcessReactions.tsx b/src/components/Reactions/hooks/useProcessReactions.tsx index 7693cc1b9..ef1403318 100644 --- a/src/components/Reactions/hooks/useProcessReactions.tsx +++ b/src/components/Reactions/hooks/useProcessReactions.tsx @@ -63,6 +63,19 @@ export const useProcessReactions = (params: UseProcessReactionsParams) => { [reactionOptions], ); + /** + * Amount of unique reaction types ("haha", "like", etc.) on a message. + */ + const uniqueReactionTypeCount = useMemo(() => { + if (!reactionGroups) { + return 0; + } + + return Object.keys(reactionGroups).filter((reactionType) => + isSupportedReaction(reactionType), + ).length; + }, [isSupportedReaction, reactionGroups]); + const getLatestReactedUserNames = useCallback( (reactionType?: string) => latestReactions?.flatMap((reaction) => { @@ -116,14 +129,15 @@ export const useProcessReactions = (params: UseProcessReactionsParams) => { const hasReactions = existingReactions.length > 0; const totalReactionCount = useMemo( - () => - existingReactions.reduce((total, { reactionCount }) => total + reactionCount, 0), - [existingReactions], + () => Object.values(reactionGroups ?? {}).reduce((total, { count }) => total + count, 0), + + [reactionGroups], ); return { existingReactions, hasReactions, totalReactionCount, - }; + uniqueReactionTypeCount, + } as const; }; diff --git a/src/components/Reactions/styling/ReactionList.scss b/src/components/Reactions/styling/ReactionList.scss index 2cf032e0c..b51a74175 100644 --- a/src/components/Reactions/styling/ReactionList.scss +++ b/src/components/Reactions/styling/ReactionList.scss @@ -1,17 +1,8 @@ @use '../../../styling/utils' as utils; +@use './common' as common; .str-chat__message-reactions { - user-select: none; display: flex; - color: var(--reaction-text); - font-feature-settings: - 'liga' off, - 'clig' off; - font-family: var(--typography-font-family-sans); - font-size: var(--typography-font-size-xxs); - font-style: normal; - font-weight: var(--typography-font-weight-bold); - line-height: 1; .str-chat__message-reactions__list { list-style: none; @@ -26,72 +17,35 @@ } } + .str-chat__message-reactions__list-item-button { + // temporary hacky fix + min-height: 26px; + } + .str-chat__message-reactions__list-button, .str-chat__message-reactions__list-item-button { - @include utils.unset-button; - display: flex; - cursor: pointer; - position: relative; - font-weight: inherit; - display: flex; - padding: var(--spacing-xxs) var(--spacing-xs); - align-items: center; - justify-content: center; - border-radius: var(--radius-max); - border: 1px solid var(--reaction-border); - background: var(--reaction-bg); - box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.16); + @include common.reaction-button; - .str-chat__message-reactions__item-icon { + .str-chat__message-reactions__list-item-icon { // FIXME: ridiculous hack so that the emoji block is in a square container (1/1 ratio) font-size: 13px; line-height: 16px; - font-family: system-ui, sans-serif; + font-family: system-ui; font-style: normal; letter-spacing: 0; display: flex; } - &:not(:disabled) { - &:hover, - &:active, - &[aria-pressed='true'] { - &::before { - content: ''; - position: absolute; - inset: 0; - border-radius: inherit; - width: 100%; - height: 100%; - } - } - - &:hover::before { - background: var(--background-core-hover); - } - &:active::before { - background: var(--background-core-pressed); - } - &[aria-pressed='true']::before { - background: var(--background-core-selected); - } - } - } - - &.str-chat__message-reactions--clustered { - .str-chat__message-reactions__list-button { - gap: var(--spacing-xxs); - - .str-chat__message-reactions__total-count { - display: flex; - align-items: center; - } - } - } - - &.str-chat__message-reactions--segmented { - .str-chat__message-reactions__list-item-button { - gap: var(--spacing-xxs); + .str-chat__message-reactions__total-count, + .str-chat__message-reactions__overflow-count, + .str-chat__message-reactions__list-item-count { + display: flex; + align-items: center; + color: var(--reaction-text); + font-family: var(--typography-font-family-sans); + font-size: var(--typography-font-size-xxs); + font-weight: var(--typography-font-weight-bold); + line-height: 1; } } } diff --git a/src/components/Reactions/styling/ReactionsListModal.scss b/src/components/Reactions/styling/ReactionsListModal.scss index f657aafe6..981cd5e8b 100644 --- a/src/components/Reactions/styling/ReactionsListModal.scss +++ b/src/components/Reactions/styling/ReactionsListModal.scss @@ -1,85 +1,140 @@ -.str-chat__message-reactions-details-modal { - .str-chat__modal__inner { - max-height: 80%; - min-width: 90%; - max-width: 90%; - width: min(480px, 90vw); - height: min(640px, 90vh); - flex-basis: min-content; - - @media only screen and (min-device-width: 768px) { - min-width: 40vh; - max-width: 60vh; - width: min-content; - } +@use '../../../styling/utils' as utils; +@use './common' as common; + +.str-chat { + .str-chat__dialog-contents:has(.str-chat__message-reactions-detail):focus-visible { + border-radius: var(--radius-lg); } } -.str-chat__message-reactions-details { - width: 100%; +.str-chat__message-reactions-detail { + border-radius: var(--radius-lg); + background: var(--background-elevation-elevation-2); + max-width: 280px; + min-width: min(90vw, 280px); + max-height: 200px; + + 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); + + padding-block-start: var(--spacing-xxs); + display: flex; flex-direction: column; - gap: var(--str-chat__spacing-4); - max-height: 100%; - height: 100%; - min-height: 0; + gap: var(--spacing-xxs); - .str-chat__message-reactions-details-reaction-types { + .str-chat__message-reactions-detail__total-count { display: flex; - max-width: 100%; - width: 100%; - min-width: 0; - overflow-x: auto; - gap: var(--str-chat__spacing-4); align-items: center; - flex-shrink: 0; + justify-content: flex-start; + padding-inline: var(--spacing-md); + padding-block: 6px; - .str-chat__message-reactions-details-reaction-type { - display: flex; - align-items: center; - padding: var(--str-chat__spacing-1) 0; - flex-shrink: 0; - cursor: pointer; - border-block-end: solid transparent; - - .str-chat__message-reaction-emoji--with-fallback { - width: 18px; - line-height: 18px; - } - } + color: var(--text-tertiary, #687385); - .str-chat__message-reactions-details-reaction-type--selected { - border-block-end: var(--str-chat__messsage-reactions-details--selected-color); - } + font-size: var(--typography-font-size-sm); + font-weight: var(--typography-font-weight-medium); + line-height: var(--typography-line-height-normal); } - .str-chat__message-reaction-emoji-big { - --str-chat__stream-emoji-size: 1em; - align-self: center; - font-size: 2rem; - } + .str-chat__message-reactions-detail__reaction-type-list-container { + display: flex; + overflow-x: auto; + width: 100%; - .str-chat__message-reaction-emoji-big.str-chat__message-reaction-emoji--with-fallback { - line-height: 2rem; + scrollbar-color: red orange; + scrollbar-width: none; } - .str-chat__message-reactions-details-reacting-users { + .str-chat__message-reactions-detail__reaction-type-list { + list-style: none; + margin: 0; + padding-inline: var(--spacing-md); + padding-block: var(--spacing-xs); display: flex; - flex-direction: column; - gap: var(--str-chat__spacing-3); - max-height: 100%; + align-items: center; + gap: var(--spacing-xxs); + + display: flex; + gap: var(--spacing-xxs); + + .str-chat__message-reactions-detail__reaction-type-list-item { + display: flex; + + .str-chat__message-reactions-detail__reaction-type-list-item-button { + @include common.reaction-button; + box-shadow: unset; + + .str-chat__message-reactions-detail__reaction-type-list-item-icon { + // FIXME: ridiculous hack so that the emoji block is in a square container (1/1 ratio) + font-size: 13px; + line-height: 16px; + font-family: system-ui; + font-style: normal; + letter-spacing: 0; + display: flex; + } + + .str-chat__message-reactions-detail__reaction-type-list-item-count { + color: var(--reaction-text); + font-size: var(--typography-font-size-xxs); + font-weight: var(--typography-font-weight-bold); + } + } + } + } + + .str-chat__message-reactions-detail__user-list { overflow-y: auto; - min-height: 30vh; + scrollbar-width: thin; + position: relative; - .str-chat__loading-indicator { - margin: auto; + .str-chat__message-reactions-detail__loading-overlay { + position: absolute; + inset: 0; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + backdrop-filter: blur(1px); + border-bottom-left-radius: var(--radius-lg); + border-bottom-right-radius: var(--radius-lg); + z-index: 1; } - .str-chat__message-reactions-details-reacting-user { + .str-chat__message-reactions-detail__user-list-item { + padding-block: var(--spacing-xs); display: flex; align-items: center; - gap: var(--str-chat__spacing-2); - font: var(--str-chat__subtitle-text); + gap: var(--spacing-xs); + padding-inline: var(--spacing-md); + + .str-chat__message-reactions-detail__user-list-item-info { + display: flex; + flex-direction: column; + gap: var(--spacing-xxxs); + + .str-chat__message-reactions-detail__user-list-item-username { + color: var(--text-primary); + font-size: var(--typography-font-size-sm); + font-weight: var(--typography-font-weight-regular); + line-height: var(--typography-line-height-normal); + } + + .str-chat__message-reactions-detail__user-list-item-button { + @include utils.unset-button; + color: var(--text-tertiary); + font-size: var(--typography-font-size-xs); + font-weight: var(--typography-font-weight-regular); + line-height: var(--typography-line-height-tight); + cursor: pointer; + } + } } } } diff --git a/src/components/Reactions/styling/common.scss b/src/components/Reactions/styling/common.scss new file mode 100644 index 000000000..a91a49200 --- /dev/null +++ b/src/components/Reactions/styling/common.scss @@ -0,0 +1,46 @@ +@use '../../../styling/utils' as utils; + +@mixin reaction-button() { + @include utils.unset-button; + display: flex; + cursor: pointer; + position: relative; + display: flex; + gap: var(--spacing-xxs); + padding: var(--spacing-xxs) var(--spacing-xs); + align-items: center; + justify-content: center; + border-radius: var(--radius-max); + border: 1px solid var(--reaction-border); + background: var(--reaction-bg); + box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.16); + font-weight: inherit; + font-size: inherit; + line-height: 1; + user-select: none; + + &:not(:disabled) { + &:hover, + &:active, + &[aria-pressed='true'] { + &::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + width: 100%; + height: 100%; + } + } + + &:hover::before { + background: var(--background-core-hover); + } + &:active::before { + background: var(--background-core-pressed); + } + &[aria-pressed='true']::before { + background: var(--background-core-selected); + } + } +} diff --git a/src/i18n/de.json b/src/i18n/de.json index cc83acede..b8ecd4c22 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -5,6 +5,8 @@ "{{ count }} files_other": "{{ count }} Dateien", "{{ count }} photos_one": "{{ count }} Foto", "{{ count }} photos_other": "{{ count }} Fotos", + "{{ count }} reactions_one": "{{ count }} reactions", + "{{ count }} reactions_other": "{{ count }} reactions", "{{ count }} videos_one": "{{ count }} Video", "{{ count }} videos_other": "{{ count }} Videos", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} und {{ secondUser }}", @@ -331,6 +333,7 @@ "Stop sharing": "Teilen beenden", "Submit": "Absenden", "Suggest an option": "Eine Option vorschlagen", + "Tap to remove": "Tap to remove", "Thinking...": "Denken...", "this content could not be displayed": "Dieser Inhalt konnte nicht angezeigt werden", "This field cannot be empty or contain only spaces": "Dieses Feld darf nicht leer sein oder nur Leerzeichen enthalten", diff --git a/src/i18n/en.json b/src/i18n/en.json index 140266656..30b99e5dc 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -5,6 +5,8 @@ "{{ count }} files_other": "{{ count }} files", "{{ count }} photos_one": "{{ count }} photo", "{{ count }} photos_other": "{{ count }} photos", + "{{ count }} reactions_one": "{{ count }} reaction", + "{{ count }} reactions_other": "{{ count }} reactions", "{{ count }} videos_one": "{{ count }} video", "{{ count }} videos_other": "{{ count }} videos", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} and {{ secondUser }}", @@ -331,6 +333,7 @@ "Stop sharing": "Stop sharing", "Submit": "Submit", "Suggest an option": "Suggest an option", + "Tap to remove": "Tap to remove", "Thinking...": "Thinking...", "this content could not be displayed": "this content could not be displayed", "This field cannot be empty or contain only spaces": "This field cannot be empty or contain only spaces", diff --git a/src/i18n/es.json b/src/i18n/es.json index de808da57..eb37336a3 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -7,6 +7,9 @@ "{{ count }} photos_one": "{{ count }} foto", "{{ count }} photos_many": "{{ count }} fotos", "{{ count }} photos_other": "{{ count }} fotos", + "{{ count }} reactions_one": "", + "{{ count }} reactions_many": "", + "{{ count }} reactions_other": "", "{{ count }} videos_one": "{{ count }} vídeo", "{{ count }} videos_many": "{{ count }} vídeos", "{{ count }} videos_other": "{{ count }} vídeos", @@ -340,6 +343,7 @@ "Stop sharing": "Dejar de compartir", "Submit": "Enviar", "Suggest an option": "Sugerir una opción", + "Tap to remove": "", "Thinking...": "Pensando...", "this content could not be displayed": "Este contenido no se pudo mostrar", "This field cannot be empty or contain only spaces": "Este campo no puede estar vacío o contener solo espacios", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 86e5293a0..4ca18d3c7 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -7,6 +7,9 @@ "{{ count }} photos_one": "{{ count }} photo", "{{ count }} photos_many": "{{ count }} photos", "{{ count }} photos_other": "{{ count }} photos", + "{{ count }} reactions_one": "", + "{{ count }} reactions_many": "", + "{{ count }} reactions_other": "", "{{ count }} videos_one": "{{ count }} vidéo", "{{ count }} videos_many": "{{ count }} vidéos", "{{ count }} videos_other": "{{ count }} vidéos", @@ -340,6 +343,7 @@ "Stop sharing": "Arrêter de partager", "Submit": "Envoyer", "Suggest an option": "Suggérer une option", + "Tap to remove": "", "Thinking...": "Réflexion...", "this content could not be displayed": "ce contenu n'a pas pu être affiché", "This field cannot be empty or contain only spaces": "Ce champ ne peut pas être vide ou contenir uniquement des espaces", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index b76e5642f..5efb57ad6 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -5,6 +5,8 @@ "{{ count }} files_other": "{{ count }} फ़ाइलें", "{{ count }} photos_one": "{{ count }} फ़ोटो", "{{ count }} photos_other": "{{ count }} फ़ोटो", + "{{ count }} reactions_one": "", + "{{ count }} reactions_other": "", "{{ count }} videos_one": "{{ count }} वीडियो", "{{ count }} videos_other": "{{ count }} वीडियो", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} और {{ secondUser }}", @@ -332,6 +334,7 @@ "Stop sharing": "साझा करना बंद करें", "Submit": "जमा करें", "Suggest an option": "एक विकल्प सुझाव दें", + "Tap to remove": "", "Thinking...": "सोच रहा है...", "this content could not be displayed": "यह कॉन्टेंट लोड नहीं हो पाया", "This field cannot be empty or contain only spaces": "यह फ़ील्ड खाली नहीं हो सकता या केवल रिक्त स्थान नहीं रख सकता", diff --git a/src/i18n/it.json b/src/i18n/it.json index a13d07d2e..057049c3d 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -7,6 +7,9 @@ "{{ count }} photos_one": "{{ count }} foto", "{{ count }} photos_many": "{{ count }} foto", "{{ count }} photos_other": "{{ count }} foto", + "{{ count }} reactions_one": "", + "{{ count }} reactions_many": "", + "{{ count }} reactions_other": "", "{{ count }} videos_one": "{{ count }} video", "{{ count }} videos_many": "{{ count }} video", "{{ count }} videos_other": "{{ count }} video", @@ -340,6 +343,7 @@ "Stop sharing": "Ferma condivisione", "Submit": "Invia", "Suggest an option": "Suggerisci un'opzione", + "Tap to remove": "", "Thinking...": "Pensando...", "this content could not be displayed": "questo contenuto non può essere mostrato", "This field cannot be empty or contain only spaces": "Questo campo non può essere vuoto o contenere solo spazi", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index e27e0352a..3ad22870a 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -5,6 +5,7 @@ "{{ count }} files_other": "{{ count }} ファイル", "{{ count }} photos_one": "{{ count }} 写真", "{{ count }} photos_other": "{{ count }} 写真", + "{{ count }} reactions_other": "", "{{ count }} videos_one": "{{ count }} 動画", "{{ count }} videos_other": "{{ count }} 動画", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} と {{ secondUser }}", @@ -331,6 +332,7 @@ "Stop sharing": "共有を停止", "Submit": "送信", "Suggest an option": "オプションを提案", + "Tap to remove": "", "Thinking...": "考え中...", "this content could not be displayed": "このコンテンツは表示できませんでした", "This field cannot be empty or contain only spaces": "このフィールドは空にすることはできません。また、空白文字のみを含むこともできません", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index b06e8e487..b9ce92c8d 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -5,6 +5,7 @@ "{{ count }} files_other": "{{ count }}개 파일", "{{ count }} photos_one": "{{ count }}개 사진", "{{ count }} photos_other": "{{ count }}개 사진", + "{{ count }} reactions_other": "", "{{ count }} videos_one": "{{ count }}개 동영상", "{{ count }} videos_other": "{{ count }}개 동영상", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} 그리고 {{ secondUser }}", @@ -331,6 +332,7 @@ "Stop sharing": "공유 중지", "Submit": "제출", "Suggest an option": "옵션 제안", + "Tap to remove": "", "Thinking...": "생각 중...", "this content could not be displayed": "이 콘텐츠를 표시할 수 없습니다", "This field cannot be empty or contain only spaces": "이 필드는 비워둘 수 없으며 공백만 포함할 수도 없습니다", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 91937effc..dc0aa7a96 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -5,6 +5,8 @@ "{{ count }} files_other": "{{ count }} bestanden", "{{ count }} photos_one": "{{ count }} foto", "{{ count }} photos_other": "{{ count }} foto's", + "{{ count }} reactions_one": "", + "{{ count }} reactions_other": "", "{{ count }} videos_one": "{{ count }} video", "{{ count }} videos_other": "{{ count }} video's", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} en {{ secondUser }}", @@ -333,6 +335,7 @@ "Stop sharing": "Delen stoppen", "Submit": "Versturen", "Suggest an option": "Stel een optie voor", + "Tap to remove": "", "Thinking...": "Denken...", "this content could not be displayed": "Deze inhoud kan niet weergegeven worden", "This field cannot be empty or contain only spaces": "Dit veld mag niet leeg zijn of alleen spaties bevatten", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 169f0557b..1aa9c3358 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -7,6 +7,9 @@ "{{ count }} photos_one": "{{ count }} foto", "{{ count }} photos_many": "{{ count }} fotos", "{{ count }} photos_other": "{{ count }} fotos", + "{{ count }} reactions_one": "", + "{{ count }} reactions_many": "", + "{{ count }} reactions_other": "", "{{ count }} videos_one": "{{ count }} vídeo", "{{ count }} videos_many": "{{ count }} vídeos", "{{ count }} videos_other": "{{ count }} vídeos", @@ -340,6 +343,7 @@ "Stop sharing": "Parar de compartilhar", "Submit": "Enviar", "Suggest an option": "Sugerir uma opção", + "Tap to remove": "", "Thinking...": "Pensando...", "this content could not be displayed": "este conteúdo não pôde ser exibido", "This field cannot be empty or contain only spaces": "Este campo não pode estar vazio ou conter apenas espaços", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 45d91a152..ccffcfca4 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -9,6 +9,10 @@ "{{ count }} photos_few": "{{ count }} фото", "{{ count }} photos_many": "{{ count }} фото", "{{ count }} photos_other": "{{ count }} фото", + "{{ count }} reactions_one": "", + "{{ count }} reactions_few": "", + "{{ count }} reactions_many": "", + "{{ count }} reactions_other": "", "{{ count }} videos_one": "{{ count }} видео", "{{ count }} videos_few": "{{ count }} видео", "{{ count }} videos_many": "{{ count }} видео", @@ -349,6 +353,7 @@ "Stop sharing": "Прекратить делиться", "Submit": "Отправить", "Suggest an option": "Предложить вариант", + "Tap to remove": "", "Thinking...": "Думаю...", "this content could not be displayed": "Этот контент не может быть отображен в данный момент", "This field cannot be empty or contain only spaces": "Это поле не может быть пустым или содержать только пробелы", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index ad73d5a64..5cd611673 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -5,6 +5,8 @@ "{{ count }} files_other": "{{ count }} dosya", "{{ count }} photos_one": "{{ count }} fotoğraf", "{{ count }} photos_other": "{{ count }} fotoğraf", + "{{ count }} reactions_one": "", + "{{ count }} reactions_other": "", "{{ count }} videos_one": "{{ count }} video", "{{ count }} videos_other": "{{ count }} video", "{{ firstUser }} and {{ secondUser }}": "{{ firstUser }} ve {{ secondUser }}", @@ -331,6 +333,7 @@ "Stop sharing": "Paylaşımı durdur", "Submit": "Gönder", "Suggest an option": "Bir seçenek önerin", + "Tap to remove": "", "Thinking...": "Düşünüyor...", "this content could not be displayed": "bu içerik gösterilemiyor", "This field cannot be empty or contain only spaces": "Bu alan boş olamaz veya sadece boşluk içeremez", From 63cc680b486ce25a9fa8b4e9fdbbf02e8b86f150 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Tue, 24 Feb 2026 12:37:46 +0100 Subject: [PATCH 2/2] Rename ReactionsListModal->MessageReactionsDetail, apply rename --- ...ctionsListModal.tsx => MessageReactionsDetail.tsx} | 11 +++++------ src/components/Reactions/ReactionsList.tsx | 6 +++--- src/components/Reactions/index.ts | 2 +- ...ionsListModal.scss => MessageReactionsDetail.scss} | 0 src/components/Reactions/styling/index.scss | 2 +- src/context/ComponentContext.tsx | 6 +++--- 6 files changed, 13 insertions(+), 14 deletions(-) rename src/components/Reactions/{ReactionsListModal.tsx => MessageReactionsDetail.tsx} (95%) rename src/components/Reactions/styling/{ReactionsListModal.scss => MessageReactionsDetail.scss} (100%) diff --git a/src/components/Reactions/ReactionsListModal.tsx b/src/components/Reactions/MessageReactionsDetail.tsx similarity index 95% rename from src/components/Reactions/ReactionsListModal.tsx rename to src/components/Reactions/MessageReactionsDetail.tsx index fbce34f7f..2cef2f111 100644 --- a/src/components/Reactions/ReactionsListModal.tsx +++ b/src/components/Reactions/MessageReactionsDetail.tsx @@ -14,7 +14,7 @@ import { import type { ReactionSort } from 'stream-chat'; import type { MessageContextValue } from '../../context'; -export type ReactionsListModalProps = Partial< +export type MessageReactionsDetailProps = Partial< Pick > & { reactions: ReactionSummary[]; @@ -28,8 +28,7 @@ export type ReactionsListModalProps = Partial< const defaultReactionDetailsSort = { created_at: -1 } as const; -// TODO: rename to MessageReactionsDetail -export function ReactionsListModal({ +export function MessageReactionsDetail({ handleFetchReactions, onSelectedReactionTypeChange, reactionDetailsSort: propReactionDetailsSort, @@ -37,17 +36,17 @@ export function ReactionsListModal({ selectedReactionType, sortReactionDetails: propSortReactionDetails, totalReactionCount, -}: ReactionsListModalProps) { +}: MessageReactionsDetailProps) { const { client } = useChatContext(); const { Avatar = DefaultAvatar, LoadingIndicator = DefaultLoadingIndicator } = - useComponentContext('ReactionsListModal'); + useComponentContext(MessageReactionsDetail.name); const { t } = useTranslationContext(); const { handleReaction: contextHandleReaction, reactionDetailsSort: contextReactionDetailsSort, sortReactionDetails: contextSortReactionDetails, - } = useMessageContext('ReactionsListModal'); + } = useMessageContext(MessageReactionsDetail.name); const legacySortReactionDetails = propSortReactionDetails ?? contextSortReactionDetails; diff --git a/src/components/Reactions/ReactionsList.tsx b/src/components/Reactions/ReactionsList.tsx index 6f40dae86..7f9d43673 100644 --- a/src/components/Reactions/ReactionsList.tsx +++ b/src/components/Reactions/ReactionsList.tsx @@ -7,7 +7,7 @@ import React, { } from 'react'; import clsx from 'clsx'; -import { ReactionsListModal as DefaultReactionsListModal } from './ReactionsListModal'; +import { MessageReactionsDetail as DefaultMessageReactionsDetail } from './MessageReactionsDetail'; import { useProcessReactions } from './hooks/useProcessReactions'; import type { MessageContextValue } from '../../context'; import { @@ -96,7 +96,7 @@ const UnMemoizedReactionsList = (props: ReactionsListProps) => { null, ); const { t } = useTranslationContext('ReactionsList'); - const { ReactionsListModal = DefaultReactionsListModal } = useComponentContext(); + const { MessageReactionsDetail = DefaultMessageReactionsDetail } = useComponentContext(); const { isMyMessage, message } = useMessageContext('ReactionsList'); const divRef = useRef>(null); @@ -216,7 +216,7 @@ const UnMemoizedReactionsList = (props: ReactionsListProps) => { trapFocus updatePositionOnContentResize > - ; /** Custom UI component to display the list of reactions on a message, defaults to and accepts same props as: [ReactionsList](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Reactions/ReactionsList.tsx) */ ReactionsList?: React.ComponentType; - /** Custom UI component to display the reactions modal, defaults to and accepts same props as: [ReactionsListModal](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Reactions/ReactionsListModal.tsx) */ - ReactionsListModal?: 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) */ + MessageReactionsDetail?: React.ComponentType; RecordingPermissionDeniedNotification?: React.ComponentType; /** Custom UI component to display the message reminder information in the Message UI, defaults to and accepts same props as: [ReminderNotification](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/ReminderNotification.tsx) */ ReminderNotification?: React.ComponentType;