signal-desktop/ts/components/conversation/ReactionViewer.tsx

263 lines
7.7 KiB
TypeScript
Raw Normal View History

2023-01-03 19:55:46 +00:00
// Copyright 2020 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
2020-01-17 22:23:19 +00:00
import * as React from 'react';
2020-10-02 20:05:09 +00:00
import { groupBy, mapValues, orderBy } from 'lodash';
2020-01-17 22:23:19 +00:00
import classNames from 'classnames';
import { ContactName } from './ContactName';
import type { Props as AvatarProps } from '../Avatar';
import { Avatar } from '../Avatar';
2020-01-17 22:23:19 +00:00
import { Emoji } from '../emoji/Emoji';
2021-09-17 22:24:21 +00:00
import { useRestoreFocus } from '../../hooks/useRestoreFocus';
import type { ConversationType } from '../../state/ducks/conversations';
2021-11-17 21:11:46 +00:00
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';
import type { EmojiData } from '../emoji/lib';
import { emojiToData } from '../emoji/lib';
2021-09-17 22:24:21 +00:00
import { useEscapeHandling } from '../../hooks/useEscapeHandling';
2021-11-17 21:11:46 +00:00
import type { ThemeType } from '../../types/Util';
2020-01-17 22:23:19 +00:00
export type Reaction = {
emoji: string;
timestamp: number;
2021-05-07 22:21:10 +00:00
from: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
2021-11-17 21:11:46 +00:00
| 'badges'
2021-05-07 22:21:10 +00:00
| 'color'
| 'id'
| 'isMe'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
>;
2020-01-17 22:23:19 +00:00
};
export type OwnProps = {
2021-11-17 21:11:46 +00:00
getPreferredBadge: PreferredBadgeSelectorType;
2020-01-17 22:23:19 +00:00
reactions: Array<Reaction>;
pickedReaction?: string;
2020-01-17 22:23:19 +00:00
onClose?: () => unknown;
2021-11-17 21:11:46 +00:00
theme: ThemeType;
2020-01-17 22:23:19 +00:00
};
export type Props = OwnProps &
Pick<React.HTMLProps<HTMLDivElement>, 'style'> &
Pick<AvatarProps, 'i18n'>;
2020-10-02 20:05:09 +00:00
const DEFAULT_EMOJI_ORDER = [
'heart',
'+1',
'-1',
'joy',
'open_mouth',
'cry',
'rage',
];
type ReactionCategory = {
2020-10-02 20:05:09 +00:00
count: number;
emoji?: string;
id: string;
index: number;
};
2020-10-02 20:05:09 +00:00
type ReactionWithEmojiData = Reaction & EmojiData;
2020-01-17 22:23:19 +00:00
export const ReactionViewer = React.forwardRef<HTMLDivElement, Props>(
2022-11-18 00:45:19 +00:00
function ReactionViewerInner(
2021-11-17 21:11:46 +00:00
{
getPreferredBadge,
i18n,
onClose,
pickedReaction,
reactions,
theme,
...rest
},
ref
2022-11-18 00:45:19 +00:00
) {
2020-10-02 20:05:09 +00:00
const reactionsWithEmojiData = React.useMemo(
() =>
reactions
.map(reaction => {
const emojiData = emojiToData(reaction.emoji);
if (!emojiData) {
return undefined;
}
return {
...reaction,
...emojiData,
};
})
.filter(
(
reactionWithEmojiData
): reactionWithEmojiData is ReactionWithEmojiData =>
Boolean(reactionWithEmojiData)
),
[reactions]
2020-01-17 22:23:19 +00:00
);
2020-10-02 20:05:09 +00:00
const groupedAndSortedReactions = React.useMemo(
() =>
mapValues(
{
all: reactionsWithEmojiData,
...groupBy(reactionsWithEmojiData, 'short_name'),
},
groupedReactions => orderBy(groupedReactions, ['timestamp'], ['desc'])
),
[reactionsWithEmojiData]
);
const reactionCategories: Array<ReactionCategory> = React.useMemo(
() =>
[
{
id: 'all',
index: 0,
count: reactionsWithEmojiData.length,
},
...Object.entries(groupedAndSortedReactions)
.filter(([key]) => key !== 'all')
.map(([, [{ short_name: id, emoji }, ...otherReactions]]) => {
return {
id,
index: DEFAULT_EMOJI_ORDER.includes(id)
? DEFAULT_EMOJI_ORDER.indexOf(id)
: Infinity,
emoji,
count: otherReactions.length + 1,
};
}),
].sort((a, b) => a.index - b.index),
[reactionsWithEmojiData, groupedAndSortedReactions]
);
2021-11-11 22:43:05 +00:00
const [selectedReactionCategory, setSelectedReactionCategory] =
React.useState(pickedReaction || 'all');
2020-01-17 22:23:19 +00:00
2021-09-17 22:24:21 +00:00
// Handle escape key
useEscapeHandling(onClose);
2020-01-17 22:23:19 +00:00
// Focus first button and restore focus on unmount
const [focusRef] = useRestoreFocus();
2020-01-17 22:23:19 +00:00
// If we have previously selected a reaction type that is no longer present
// (removed on another device, for instance) we should select another
// reaction type
React.useEffect(() => {
2020-10-02 20:05:09 +00:00
if (
!reactionCategories.find(({ id }) => id === selectedReactionCategory)
) {
if (reactionsWithEmojiData.length > 0) {
setSelectedReactionCategory('all');
} else if (onClose) {
onClose();
}
}
2020-10-02 20:05:09 +00:00
}, [
reactionCategories,
onClose,
reactionsWithEmojiData,
selectedReactionCategory,
]);
2020-10-02 20:05:09 +00:00
const selectedReactions =
groupedAndSortedReactions[selectedReactionCategory] || [];
2020-01-17 22:23:19 +00:00
return (
<div {...rest} ref={ref} className="module-reaction-viewer">
<header className="module-reaction-viewer__header">
2020-10-02 20:05:09 +00:00
{reactionCategories.map(({ id, emoji, count }, index) => {
const isAll = index === 0;
const maybeFocusRef = isAll ? focusRef : undefined;
return (
<button
type="button"
key={id}
ref={maybeFocusRef}
className={classNames(
'module-reaction-viewer__header__button',
selectedReactionCategory === id
? 'module-reaction-viewer__header__button--selected'
: null
)}
onClick={event => {
event.stopPropagation();
setSelectedReactionCategory(id);
}}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === 'Space') {
event.stopPropagation();
event.preventDefault();
setSelectedReactionCategory(id);
}
}}
2020-10-02 20:05:09 +00:00
>
{isAll ? (
<span className="module-reaction-viewer__header__button__all">
{i18n('ReactionsViewer--all')}&thinsp;&middot;&thinsp;
{count}
</span>
) : (
<>
<Emoji size={18} emoji={emoji} />
<span className="module-reaction-viewer__header__button__count">
{count}
2020-02-05 23:14:25 +00:00
</span>
2020-10-02 20:05:09 +00:00
</>
)}
</button>
);
})}
2020-01-17 22:23:19 +00:00
</header>
<main className="module-reaction-viewer__body">
{selectedReactions.map(({ from, emoji }) => (
2020-01-17 22:23:19 +00:00
<div
key={`${from.id}-${emoji}`}
2020-01-17 22:23:19 +00:00
className="module-reaction-viewer__body__row"
>
<div className="module-reaction-viewer__body__row__avatar">
<Avatar
2021-05-07 22:21:10 +00:00
acceptedMessageRequest={from.acceptedMessageRequest}
avatarPath={from.avatarPath}
2021-11-17 21:11:46 +00:00
badge={getPreferredBadge(from.badges)}
2020-01-17 22:23:19 +00:00
conversationType="direct"
2021-05-07 22:21:10 +00:00
sharedGroupNames={from.sharedGroupNames}
2020-01-17 22:23:19 +00:00
size={32}
2021-05-07 22:21:10 +00:00
isMe={from.isMe}
color={from.color}
profileName={from.profileName}
phoneNumber={from.phoneNumber}
2021-11-17 21:11:46 +00:00
theme={theme}
2020-07-24 01:35:32 +00:00
title={from.title}
2020-01-17 22:23:19 +00:00
i18n={i18n}
/>
</div>
2020-02-05 23:14:25 +00:00
<div className="module-reaction-viewer__body__row__name">
{from.isMe ? (
i18n('you')
) : (
<ContactName
module="module-reaction-viewer__body__row__name__contact-name"
2020-07-24 01:35:32 +00:00
title={from.title}
/>
)}
2020-02-05 23:14:25 +00:00
</div>
<div className="module-reaction-viewer__body__row__emoji">
<Emoji size={18} emoji={emoji} />
</div>
2020-01-17 22:23:19 +00:00
</div>
))}
</main>
</div>
);
}
);