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/MessageReactionsDetail.tsx b/src/components/Reactions/MessageReactionsDetail.tsx new file mode 100644 index 000000000..2cef2f111 --- /dev/null +++ b/src/components/Reactions/MessageReactionsDetail.tsx @@ -0,0 +1,166 @@ +import React, { useMemo } from 'react'; + +import type { ReactionDetailsComparator, ReactionSummary, ReactionType } from './types'; + +import { useFetchReactions } from './hooks/useFetchReactions'; +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 MessageReactionsDetailProps = 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; + +export function MessageReactionsDetail({ + handleFetchReactions, + onSelectedReactionTypeChange, + reactionDetailsSort: propReactionDetailsSort, + reactions, + selectedReactionType, + sortReactionDetails: propSortReactionDetails, + totalReactionCount, +}: MessageReactionsDetailProps) { + const { client } = useChatContext(); + const { Avatar = DefaultAvatar, LoadingIndicator = DefaultLoadingIndicator } = + useComponentContext(MessageReactionsDetail.name); + const { t } = useTranslationContext(); + + const { + handleReaction: contextHandleReaction, + reactionDetailsSort: contextReactionDetailsSort, + sortReactionDetails: contextSortReactionDetails, + } = useMessageContext(MessageReactionsDetail.name); + + const legacySortReactionDetails = propSortReactionDetails ?? contextSortReactionDetails; + + const reactionDetailsSort = + propReactionDetailsSort ?? contextReactionDetailsSort ?? defaultReactionDetailsSort; + + const { + isLoading: areReactionsLoading, + reactions: reactionDetails, + refetch, + } = useFetchReactions({ + handleFetchReactions, + reactionType: selectedReactionType, + shouldFetch: true, + sort: reactionDetailsSort, + }); + + const reactionDetailsWithLegacyFallback = useMemo( + () => + legacySortReactionDetails + ? [...reactionDetails].sort(legacySortReactionDetails) + : reactionDetails, + [legacySortReactionDetails, reactionDetails], + ); + + return ( +
+ {typeof totalReactionCount === 'number' && ( +
+ {t('{{ count }} reactions', { count: totalReactionCount })} +
+ )} +
+
    + {reactions.map( + ({ EmojiComponent, reactionCount, reactionType }) => + EmojiComponent && ( +
  • + +
  • + ), + )} +
+
+
+ {areReactionsLoading && ( +
+ +
+ )} + {reactionDetailsWithLegacyFallback.map(({ user }) => { + const belongsToCurrentUser = client.user?.id === user?.id; + return ( +
+ +
+ + {belongsToCurrentUser ? t('You') : user?.name || user?.id} + + {belongsToCurrentUser && selectedReactionType && ( + + )} +
+
+ ); + })} +
+
+ ); +} diff --git a/src/components/Reactions/ReactionsList.tsx b/src/components/Reactions/ReactionsList.tsx index 54d401755..7f9d43673 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 { MessageReactionsDetail as DefaultMessageReactionsDetail } from './MessageReactionsDetail'; 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 { MessageReactionsDetail = DefaultMessageReactionsDetail } = 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 deleted file mode 100644 index e69ae75c2..000000000 --- a/src/components/Reactions/ReactionsListModal.tsx +++ /dev/null @@ -1,136 +0,0 @@ -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 type { ReactionSort } from 'stream-chat'; - -export type ReactionsListModalProps = ModalProps & - Partial> & { - reactions: ReactionSummary[]; - selectedReactionType: ReactionType; - onSelectedReactionTypeChange?: (reactionType: ReactionType) => void; - sort?: ReactionSort; - /** @deprecated use `sort` instead */ - sortReactionDetails?: ReactionDetailsComparator; - }; - -const defaultReactionDetailsSort = { created_at: -1 } as const; - -export function ReactionsListModal({ - handleFetchReactions, - onSelectedReactionTypeChange, - reactionDetailsSort: propReactionDetailsSort, - reactions, - selectedReactionType, - sortReactionDetails: propSortReactionDetails, - ...modalProps -}: ReactionsListModalProps) { - const { Modal = GlobalModal } = useComponentContext(); - const selectedReaction = reactions.find( - ({ reactionType }) => reactionType === selectedReactionType, - ); - const SelectedEmojiComponent = selectedReaction?.EmojiComponent ?? null; - const { - 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 reactionDetailsWithLegacyFallback = useMemo( - () => - legacySortReactionDetails - ? [...reactionDetails].sort(legacySortReactionDetails) - : reactionDetails, - [legacySortReactionDetails, reactionDetails], - ); - - return ( - -
-
- {reactions.map( - ({ EmojiComponent, reactionCount, reactionType }) => - EmojiComponent && ( -
- onSelectedReactionTypeChange?.(reactionType as ReactionType) - } - > - - - -   - - {reactionCount} - -
- ), - )} -
- {SelectedEmojiComponent && ( -
- -
- )} -
- {areReactionsLoading ? ( - - ) : ( - reactionDetailsWithLegacyFallback.map(({ user }) => ( -
- - - {user?.name || user?.id} - -
- )) - )} -
-
-
- ); -} 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/index.ts b/src/components/Reactions/index.ts index d17fd6d92..7a3c5d29f 100644 --- a/src/components/Reactions/index.ts +++ b/src/components/Reactions/index.ts @@ -1,6 +1,6 @@ export * from './ReactionSelector'; export * from './ReactionsList'; -export * from './ReactionsListModal'; +export * from './MessageReactionsDetail'; export * from './SimpleReactionsList'; export * from './SpriteImage'; export * from './StreamEmoji'; diff --git a/src/components/Reactions/styling/MessageReactionsDetail.scss b/src/components/Reactions/styling/MessageReactionsDetail.scss new file mode 100644 index 000000000..981cd5e8b --- /dev/null +++ b/src/components/Reactions/styling/MessageReactionsDetail.scss @@ -0,0 +1,140 @@ +@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-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(--spacing-xxs); + + .str-chat__message-reactions-detail__total-count { + display: flex; + align-items: center; + justify-content: flex-start; + padding-inline: var(--spacing-md); + padding-block: 6px; + + color: var(--text-tertiary, #687385); + + font-size: var(--typography-font-size-sm); + font-weight: var(--typography-font-weight-medium); + line-height: var(--typography-line-height-normal); + } + + .str-chat__message-reactions-detail__reaction-type-list-container { + display: flex; + overflow-x: auto; + width: 100%; + + scrollbar-color: red orange; + scrollbar-width: none; + } + + .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; + 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; + scrollbar-width: thin; + position: relative; + + .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-detail__user-list-item { + padding-block: var(--spacing-xs); + display: flex; + align-items: center; + 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/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 deleted file mode 100644 index f657aafe6..000000000 --- a/src/components/Reactions/styling/ReactionsListModal.scss +++ /dev/null @@ -1,85 +0,0 @@ -.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; - } - } -} - -.str-chat__message-reactions-details { - width: 100%; - display: flex; - flex-direction: column; - gap: var(--str-chat__spacing-4); - max-height: 100%; - height: 100%; - min-height: 0; - - .str-chat__message-reactions-details-reaction-types { - 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; - - .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; - } - } - - .str-chat__message-reactions-details-reaction-type--selected { - border-block-end: var(--str-chat__messsage-reactions-details--selected-color); - } - } - - .str-chat__message-reaction-emoji-big { - --str-chat__stream-emoji-size: 1em; - align-self: center; - font-size: 2rem; - } - - .str-chat__message-reaction-emoji-big.str-chat__message-reaction-emoji--with-fallback { - line-height: 2rem; - } - - .str-chat__message-reactions-details-reacting-users { - display: flex; - flex-direction: column; - gap: var(--str-chat__spacing-3); - max-height: 100%; - overflow-y: auto; - min-height: 30vh; - - .str-chat__loading-indicator { - margin: auto; - } - - .str-chat__message-reactions-details-reacting-user { - display: flex; - align-items: center; - gap: var(--str-chat__spacing-2); - font: var(--str-chat__subtitle-text); - } - } -} 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/components/Reactions/styling/index.scss b/src/components/Reactions/styling/index.scss index 2ff58635a..0b359446d 100644 --- a/src/components/Reactions/styling/index.scss +++ b/src/components/Reactions/styling/index.scss @@ -1,3 +1,3 @@ @use 'ReactionList'; @use 'ReactionSelector'; -@use 'ReactionsListModal'; +@use 'MessageReactionsDetail'; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index c6d1c4498..8c31ea8f7 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -24,6 +24,7 @@ import { type MessageListNotificationsProps, type MessageNotificationProps, type MessageProps, + type MessageReactionsDetailProps, type MessageRepliesCountButtonProps, type MessageStatusProps, type MessageTimestampProps, @@ -36,7 +37,6 @@ import { type QuotedMessagePreviewProps, type ReactionOptions, type ReactionSelectorProps, - type ReactionsListModalProps, type ReactionsListProps, type RecordingPermissionDeniedNotificationProps, type ReminderNotificationProps, @@ -186,8 +186,8 @@ export type ComponentContextValue = { ReactionSelector?: React.ForwardRefExoticComponent; /** 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; 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",