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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 87 additions & 54 deletions src/components/Message/MessageStatus.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React from 'react';
import React, { useState } from 'react';
import clsx from 'clsx';

import { DeliveredCheckIcon, MessageDeliveredIcon } from './icons';
import { getReadByTooltipText, mapToUserNameOrId, TooltipUsernameMapper } from './utils';

import { AvatarProps, Avatar as DefaultAvatar } from '../Avatar';
import { LoadingIndicator } from '../Loading';
import { Tooltip } from '../Tooltip';
import { PopperTooltip, Tooltip } from '../Tooltip';
import { useEnterLeaveHandlers } from '../Tooltip/hooks';

import { useChatContext } from '../../context/ChatContext';
import { useComponentContext } from '../../context/ComponentContext';
Expand All @@ -20,16 +22,6 @@ export type MessageStatusProps = {
tooltipUserNameMapper?: TooltipUsernameMapper;
};

// TODO: remove after fully deprecating V1 theming
const TooltipContainer = ({ children }: React.PropsWithChildren<Record<never, never>>) => {
const { themeVersion } = useChatContext('TooltipContainer');
return themeVersion === '2' ? (
<div className='str-chat__message-status-tooltip-container'>{children}</div>
) : (
<>{children}</>
);
};

const UnMemoizedMessageStatus = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
>(
Expand All @@ -41,6 +33,8 @@ const UnMemoizedMessageStatus = <
tooltipUserNameMapper = mapToUserNameOrId,
} = props;

const { handleEnter, handleLeave, popperVisible } = useEnterLeaveHandlers<HTMLSpanElement>();

const { client } = useChatContext<StreamChatGenerics>('MessageStatus');
const { Avatar: contextAvatar } = useComponentContext<StreamChatGenerics>('MessageStatus');
const {
Expand All @@ -52,61 +46,100 @@ const UnMemoizedMessageStatus = <
} = useMessageContext<StreamChatGenerics>('MessageStatus');
const { t } = useTranslationContext('MessageStatus');
const { themeVersion } = useChatContext('MessageStatus');
const [referenceElement, setReferenceElement] = useState<HTMLSpanElement | null>(null);

const Avatar = propAvatar || contextAvatar || DefaultAvatar;

if (!isMyMessage() || message.type === 'error') {
return null;
}
if (!isMyMessage() || message.type === 'error') return null;

const justReadByMe = readBy?.length === 1 && readBy[0].id === client.user?.id;
const rootClassName = `str-chat__message-${messageType}-status str-chat__message-status`;

if (message.status === 'sending') {
return (
<span className={rootClassName} data-testid='message-status-sending'>
<Tooltip>{t<string>('Sending...')}</Tooltip>
<LoadingIndicator />
</span>
);
}

if (readBy?.length && !threadList && !justReadByMe) {
const [lastReadUser] = readBy.filter((item) => item.id !== client.user?.id);

return (
<span className={rootClassName} data-testid='message-status-read-by'>
<TooltipContainer>
<Tooltip>{getReadByTooltipText(readBy, t, client, tooltipUserNameMapper)}</Tooltip>
const sending = message.status === 'sending';
const delivered = message.status === 'received' && message.id === lastReceivedId && !threadList;
const deliveredAndRead = !!(readBy?.length && !threadList && !justReadByMe);

const [lastReadUser] = deliveredAndRead
? readBy.filter((item) => item.id !== client.user?.id)
: [];

return (
<span
className={rootClassName}
data-testid={clsx({
'message-status-read-by': deliveredAndRead,
'message-status-received': delivered && !deliveredAndRead,
'message-status-sending': sending,
})}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
ref={setReferenceElement}
>
{sending && (
<>
{themeVersion === '1' && <Tooltip>{t<string>('Sending...')}</Tooltip>}
{themeVersion === '2' && (
<PopperTooltip
offset={[0, 5]}
referenceElement={referenceElement}
visible={popperVisible}
>
{t<string>('Sending...')}
</PopperTooltip>
)}
<LoadingIndicator />
</>
)}

{delivered && !deliveredAndRead && (
<>
{themeVersion === '1' && <Tooltip>{t<string>('Delivered')}</Tooltip>}
{themeVersion === '2' && (
<PopperTooltip
offset={[0, 5]}
referenceElement={referenceElement}
visible={popperVisible}
>
{t<string>('Delivered')}
</PopperTooltip>
)}
{themeVersion === '2' ? <MessageDeliveredIcon /> : <DeliveredCheckIcon />}
</>
)}

{deliveredAndRead && (
<>
{themeVersion === '1' && (
<Tooltip>{getReadByTooltipText(readBy, t, client, tooltipUserNameMapper)}</Tooltip>
)}
{themeVersion === '2' && (
<PopperTooltip
offset={[0, 5]}
referenceElement={referenceElement}
visible={popperVisible}
>
{getReadByTooltipText(readBy, t, client, tooltipUserNameMapper)}
</PopperTooltip>
)}
<Avatar
image={lastReadUser.image}
name={lastReadUser.name || lastReadUser.id}
size={15}
user={lastReadUser}
/>
</TooltipContainer>
{readBy.length > 2 && (
<span
className={`str-chat__message-${messageType}-status-number`}
data-testid='message-status-read-by-many'
>
{readBy.length - 1}
</span>
)}
</span>
);
}

if (message.status === 'received' && message.id === lastReceivedId && !threadList) {
return (
<span className={rootClassName} data-testid='message-status-received'>
<Tooltip>{t<string>('Delivered')}</Tooltip>
{themeVersion === '2' ? <MessageDeliveredIcon /> : <DeliveredCheckIcon />}
</span>
);
}

return null;

{readBy.length > 2 && (
<span
className={`str-chat__message-${messageType}-status-number`}
data-testid='message-status-read-by-many'
>
{readBy.length - 1}
</span>
)}
</>
)}
</span>
);
};

export const MessageStatus = React.memo(UnMemoizedMessageStatus) as typeof UnMemoizedMessageStatus;
49 changes: 46 additions & 3 deletions src/components/Reactions/ReactionsList.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React, { Suspense } from 'react';
import React, { ComponentProps, Suspense, useState } from 'react';
import clsx from 'clsx';

import { useEmojiContext } from '../../context/EmojiContext';
import { useMessageContext } from '../../context/MessageContext';
import { useChatContext } from '../../context/ChatContext';
import { useProcessReactions } from './hooks/useProcessReactions';

import type { NimbleEmojiProps } from 'emoji-mart';
Expand All @@ -13,6 +14,9 @@ import type { ReactEventHandler } from '../Message/types';
import type { DefaultStreamChatGenerics } from '../../types/types';
import type { ReactionEmoji } from '../Channel/emojiData';

import { PopperTooltip } from '../Tooltip';
import { useEnterLeaveHandlers } from '../Tooltip/hooks';

export type ReactionsListProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
> = {
Expand All @@ -32,6 +36,40 @@ export type ReactionsListProps<
reverse?: boolean;
};

const ButtonWithTooltip = ({
children,
onMouseEnter,
onMouseLeave,
...rest
}: Omit<ComponentProps<'button'>, 'ref'>) => {
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);

const { handleEnter, handleLeave, popperVisible } = useEnterLeaveHandlers({
onMouseEnter,
onMouseLeave,
});

const { themeVersion } = useChatContext('ButtonWithTooltip');

return (
<>
{themeVersion === '2' && (
<PopperTooltip referenceElement={referenceElement} visible={popperVisible}>
{rest.title}
</PopperTooltip>
)}
<button
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
ref={setReferenceElement}
{...rest}
>
{children}
</button>
</>
);
};

const UnMemoizedReactionsList = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
>(
Expand All @@ -44,6 +82,7 @@ const UnMemoizedReactionsList = <

const {
additionalEmojiProps,
aggregatedNamesByType,
emojiData,
getEmojiByReactionType,
iHaveReactedWithReaction,
Expand Down Expand Up @@ -80,7 +119,11 @@ const UnMemoizedReactionsList = <
})}
key={emojiObject.id}
>
<button aria-label={`Reactions: ${reactionType}`}>
<ButtonWithTooltip
aria-label={`Reactions: ${reactionType}`}
title={aggregatedNamesByType[reactionType].join(', ')}
type='button'
>
{
<Suspense fallback={null}>
<span className='str-chat__message-reaction-emoji'>
Expand All @@ -100,7 +143,7 @@ const UnMemoizedReactionsList = <
>
{reactionCounts[reactionType]}
</span>
</button>
</ButtonWithTooltip>
</li>
) : null;
})}
Expand Down
13 changes: 13 additions & 0 deletions src/components/Reactions/hooks/useProcessReactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,21 @@ export const useProcessReactions = <
[reactionCounts, supportedReactionsArePresent],
);

const aggregatedNamesByType = useMemo(
() =>
latestReactions.reduce<Record<string, Array<string>>>((typeMap, { type, user }) => {
typeMap[type] ??= [];

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
typeMap[type].push(user?.name || user!.id);
return typeMap;
}, {}),
[latestReactions],
);

return {
additionalEmojiProps: reactionsAreCustom ? additionalEmojiProps : emojiSetDef,
aggregatedNamesByType,
emojiData,
getEmojiByReactionType,
iHaveReactedWithReaction,
Expand Down
45 changes: 41 additions & 4 deletions src/components/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,46 @@
import React, { ComponentProps } from 'react';
import React, { ComponentProps, useState } from 'react';
import { PopperProps, usePopper } from 'react-popper';

export const Tooltip = ({ children, ...rest }: ComponentProps<'div'>) => (
<div className='str-chat__tooltip' {...rest}>
{children}
</div>
);

export const PopperTooltip = <T extends HTMLElement>({
children,
offset = [0, 10],
referenceElement,
placement = 'top',
visible: visible = false,
}: React.PropsWithChildren<{
referenceElement: T | null;
offset?: [number, number];
placement?: PopperProps<unknown>['placement'];
visible?: boolean;
}>) => {
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { attributes, styles } = usePopper(referenceElement, popperElement, {
modifiers: [
{
name: 'offset',
options: {
offset,
},
},
],
placement,
});

if (!visible) return null;

export const Tooltip = (props: ComponentProps<'div'>) => {
const { children, ...rest } = props;
return (
<div className='str-chat__tooltip' {...rest}>
<div
className='str-chat__tooltip str-chat__button-tooltip'
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
{children}
</div>
);
Expand Down
1 change: 1 addition & 0 deletions src/components/Tooltip/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useEnterLeaveHandlers';
26 changes: 26 additions & 0 deletions src/components/Tooltip/hooks/useEnterLeaveHandlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React, { useCallback, useState } from 'react';

export const useEnterLeaveHandlers = <T extends HTMLElement>({
onMouseEnter,
onMouseLeave,
}: Partial<Record<'onMouseEnter' | 'onMouseLeave', React.MouseEventHandler<T>>> = {}) => {
const [popperVisible, setPopperVisible] = useState(false);

const handleEnter: React.MouseEventHandler<T> = useCallback(
(e) => {
setPopperVisible(true);
onMouseEnter?.(e);
},
[onMouseEnter],
);

const handleLeave: React.MouseEventHandler<T> = useCallback(
(e) => {
setPopperVisible(false);
onMouseLeave?.(e);
},
[onMouseLeave],
);

return { handleEnter, handleLeave, popperVisible };
};