410 lines
13 KiB
TypeScript
410 lines
13 KiB
TypeScript
// Copyright 2022 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import classNames from 'classnames';
|
|
import { usePopper } from 'react-popper';
|
|
import type { AttachmentType } from '../types/Attachment';
|
|
import type { BodyRangeType, LocalizerType } from '../types/Util';
|
|
import type { ContactNameColorType } from '../types/Colors';
|
|
import type { ConversationType } from '../state/ducks/conversations';
|
|
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
|
import type { InputApi } from './CompositionInput';
|
|
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
|
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
|
|
import type { ReplyType } from '../types/Stories';
|
|
import { Avatar, AvatarSize } from './Avatar';
|
|
import { CompositionInput } from './CompositionInput';
|
|
import { ContactName } from './conversation/ContactName';
|
|
import { EmojiButton } from './emoji/EmojiButton';
|
|
import { Emojify } from './conversation/Emojify';
|
|
import { MessageBody } from './conversation/MessageBody';
|
|
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
|
import { Modal } from './Modal';
|
|
import { Quote } from './conversation/Quote';
|
|
import { ReactionPicker } from './conversation/ReactionPicker';
|
|
import { Tabs } from './Tabs';
|
|
import { Theme } from '../util/theme';
|
|
import { ThemeType } from '../types/Util';
|
|
import { getAvatarColor } from '../types/Colors';
|
|
|
|
type ViewType = Pick<
|
|
ConversationType,
|
|
| 'acceptedMessageRequest'
|
|
| 'avatarPath'
|
|
| 'color'
|
|
| 'isMe'
|
|
| 'name'
|
|
| 'profileName'
|
|
| 'sharedGroupNames'
|
|
| 'title'
|
|
> & {
|
|
contactNameColor?: ContactNameColorType;
|
|
timestamp: number;
|
|
};
|
|
|
|
enum Tab {
|
|
Replies = 'Replies',
|
|
Views = 'Views',
|
|
}
|
|
|
|
export type PropsType = {
|
|
authorTitle: string;
|
|
getPreferredBadge: PreferredBadgeSelectorType;
|
|
i18n: LocalizerType;
|
|
isGroupStory?: boolean;
|
|
isMyStory?: boolean;
|
|
onClose: () => unknown;
|
|
onReact: (emoji: string) => unknown;
|
|
onReply: (
|
|
message: string,
|
|
mentions: Array<BodyRangeType>,
|
|
timestamp: number
|
|
) => unknown;
|
|
onSetSkinTone: (tone: number) => unknown;
|
|
onTextTooLong: () => unknown;
|
|
onUseEmoji: (_: EmojiPickDataType) => unknown;
|
|
preferredReactionEmoji: Array<string>;
|
|
recentEmojis?: Array<string>;
|
|
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
|
|
replies: Array<ReplyType>;
|
|
skinTone?: number;
|
|
storyPreviewAttachment?: AttachmentType;
|
|
views: Array<ViewType>;
|
|
};
|
|
|
|
export const StoryViewsNRepliesModal = ({
|
|
authorTitle,
|
|
getPreferredBadge,
|
|
i18n,
|
|
isGroupStory,
|
|
isMyStory,
|
|
onClose,
|
|
onReact,
|
|
onReply,
|
|
onSetSkinTone,
|
|
onTextTooLong,
|
|
onUseEmoji,
|
|
preferredReactionEmoji,
|
|
recentEmojis,
|
|
renderEmojiPicker,
|
|
replies,
|
|
skinTone,
|
|
storyPreviewAttachment,
|
|
views,
|
|
}: PropsType): JSX.Element => {
|
|
const inputApiRef = useRef<InputApi | undefined>();
|
|
const [bottom, setBottom] = useState<HTMLDivElement | null>(null);
|
|
const [messageBodyText, setMessageBodyText] = useState('');
|
|
const [showReactionPicker, setShowReactionPicker] = useState(false);
|
|
|
|
const focusComposer = useCallback(() => {
|
|
if (inputApiRef.current) {
|
|
inputApiRef.current.focus();
|
|
}
|
|
}, [inputApiRef]);
|
|
|
|
const insertEmoji = useCallback(
|
|
(e: EmojiPickDataType) => {
|
|
if (inputApiRef.current) {
|
|
inputApiRef.current.insertEmoji(e);
|
|
onUseEmoji(e);
|
|
}
|
|
},
|
|
[inputApiRef, onUseEmoji]
|
|
);
|
|
|
|
const [referenceElement, setReferenceElement] =
|
|
useState<HTMLButtonElement | null>(null);
|
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
|
|
null
|
|
);
|
|
|
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
|
placement: 'top-start',
|
|
strategy: 'fixed',
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (replies.length) {
|
|
bottom?.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
}, [bottom, replies.length]);
|
|
|
|
let composerElement: JSX.Element | undefined;
|
|
|
|
if (!isMyStory) {
|
|
composerElement = (
|
|
<div className="StoryViewsNRepliesModal__compose-container">
|
|
<div className="StoryViewsNRepliesModal__composer">
|
|
{!isGroupStory && (
|
|
<Quote
|
|
authorTitle={authorTitle}
|
|
conversationColor="ultramarine"
|
|
i18n={i18n}
|
|
isFromMe={false}
|
|
isViewOnce={false}
|
|
moduleClassName="StoryViewsNRepliesModal__quote"
|
|
rawAttachment={storyPreviewAttachment}
|
|
referencedMessageNotFound={false}
|
|
text={i18n('message--getNotificationText--text-with-emoji', {
|
|
text: i18n('message--getNotificationText--photo'),
|
|
emoji: '📷',
|
|
})}
|
|
/>
|
|
)}
|
|
<CompositionInput
|
|
draftText={messageBodyText}
|
|
getPreferredBadge={getPreferredBadge}
|
|
i18n={i18n}
|
|
inputApi={inputApiRef}
|
|
moduleClassName="StoryViewsNRepliesModal__input"
|
|
onEditorStateChange={messageText => {
|
|
setMessageBodyText(messageText);
|
|
}}
|
|
onPickEmoji={insertEmoji}
|
|
onSubmit={(...args) => {
|
|
inputApiRef.current?.reset();
|
|
onReply(...args);
|
|
}}
|
|
onTextTooLong={onTextTooLong}
|
|
placeholder={i18n('StoryViewsNRepliesModal__placeholder')}
|
|
theme={ThemeType.dark}
|
|
>
|
|
<EmojiButton
|
|
className="StoryViewsNRepliesModal__emoji-button"
|
|
i18n={i18n}
|
|
onPickEmoji={insertEmoji}
|
|
onClose={focusComposer}
|
|
recentEmojis={recentEmojis}
|
|
skinTone={skinTone}
|
|
onSetSkinTone={onSetSkinTone}
|
|
/>
|
|
</CompositionInput>
|
|
</div>
|
|
<button
|
|
aria-label={i18n('StoryViewsNRepliesModal__react')}
|
|
className="StoryViewsNRepliesModal__react"
|
|
onClick={() => {
|
|
setShowReactionPicker(!showReactionPicker);
|
|
}}
|
|
ref={setReferenceElement}
|
|
type="button"
|
|
/>
|
|
{showReactionPicker && (
|
|
<div
|
|
ref={setPopperElement}
|
|
style={styles.popper}
|
|
{...attributes.popper}
|
|
>
|
|
<ReactionPicker
|
|
i18n={i18n}
|
|
onClose={() => {
|
|
setShowReactionPicker(false);
|
|
}}
|
|
onPick={emoji => {
|
|
setShowReactionPicker(false);
|
|
onReact(emoji);
|
|
}}
|
|
onSetSkinTone={onSetSkinTone}
|
|
preferredReactionEmoji={preferredReactionEmoji}
|
|
renderEmojiPicker={renderEmojiPicker}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
let repliesElement: JSX.Element | undefined;
|
|
|
|
if (replies.length) {
|
|
repliesElement = (
|
|
<div className="StoryViewsNRepliesModal__replies">
|
|
{replies.map(reply =>
|
|
reply.reactionEmoji ? (
|
|
<div className="StoryViewsNRepliesModal__reaction" key={reply.id}>
|
|
<div className="StoryViewsNRepliesModal__reaction--container">
|
|
<Avatar
|
|
acceptedMessageRequest={reply.acceptedMessageRequest}
|
|
avatarPath={reply.avatarPath}
|
|
badge={undefined}
|
|
color={getAvatarColor(reply.color)}
|
|
conversationType="direct"
|
|
i18n={i18n}
|
|
isMe={Boolean(reply.isMe)}
|
|
name={reply.name}
|
|
profileName={reply.profileName}
|
|
sharedGroupNames={reply.sharedGroupNames || []}
|
|
size={AvatarSize.TWENTY_EIGHT}
|
|
title={reply.title}
|
|
/>
|
|
<div className="StoryViewsNRepliesModal__reaction--body">
|
|
<div className="StoryViewsNRepliesModal__reply--title">
|
|
<ContactName
|
|
contactNameColor={reply.contactNameColor}
|
|
title={reply.title}
|
|
/>
|
|
</div>
|
|
{i18n('StoryViewsNRepliesModal__reacted')}
|
|
<MessageTimestamp
|
|
i18n={i18n}
|
|
module="StoryViewsNRepliesModal__reply--timestamp"
|
|
timestamp={reply.timestamp}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Emojify text={reply.reactionEmoji} />
|
|
</div>
|
|
) : (
|
|
<div className="StoryViewsNRepliesModal__reply" key={reply.id}>
|
|
<Avatar
|
|
acceptedMessageRequest={reply.acceptedMessageRequest}
|
|
avatarPath={reply.avatarPath}
|
|
badge={undefined}
|
|
color={getAvatarColor(reply.color)}
|
|
conversationType="direct"
|
|
i18n={i18n}
|
|
isMe={Boolean(reply.isMe)}
|
|
name={reply.name}
|
|
profileName={reply.profileName}
|
|
sharedGroupNames={reply.sharedGroupNames || []}
|
|
size={AvatarSize.TWENTY_EIGHT}
|
|
title={reply.title}
|
|
/>
|
|
<div
|
|
className={classNames(
|
|
'StoryViewsNRepliesModal__message-bubble',
|
|
{
|
|
'StoryViewsNRepliesModal__message-bubble--doe': Boolean(
|
|
reply.deletedForEveryone
|
|
),
|
|
}
|
|
)}
|
|
>
|
|
<div className="StoryViewsNRepliesModal__reply--title">
|
|
<ContactName
|
|
contactNameColor={reply.contactNameColor}
|
|
title={reply.title}
|
|
/>
|
|
</div>
|
|
|
|
<MessageBody
|
|
disableJumbomoji
|
|
i18n={i18n}
|
|
text={
|
|
reply.deletedForEveryone
|
|
? i18n('message--deletedForEveryone')
|
|
: String(reply.body)
|
|
}
|
|
/>
|
|
|
|
<MessageTimestamp
|
|
i18n={i18n}
|
|
module="StoryViewsNRepliesModal__reply--timestamp"
|
|
timestamp={reply.timestamp}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
)}
|
|
<div ref={setBottom} />
|
|
</div>
|
|
);
|
|
} else if (isGroupStory) {
|
|
repliesElement = (
|
|
<div className="StoryViewsNRepliesModal__replies--none">
|
|
{i18n('StoryViewsNRepliesModal__no-replies')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const viewsElement = views.length ? (
|
|
<div className="StoryViewsNRepliesModal__views">
|
|
{views.map(view => (
|
|
<div className="StoryViewsNRepliesModal__view" key={view.timestamp}>
|
|
<div>
|
|
<Avatar
|
|
acceptedMessageRequest={view.acceptedMessageRequest}
|
|
avatarPath={view.avatarPath}
|
|
badge={undefined}
|
|
color={getAvatarColor(view.color)}
|
|
conversationType="direct"
|
|
i18n={i18n}
|
|
isMe={Boolean(view.isMe)}
|
|
name={view.name}
|
|
profileName={view.profileName}
|
|
sharedGroupNames={view.sharedGroupNames || []}
|
|
size={AvatarSize.TWENTY_EIGHT}
|
|
title={view.title}
|
|
/>
|
|
<span className="StoryViewsNRepliesModal__view--name">
|
|
<ContactName
|
|
contactNameColor={view.contactNameColor}
|
|
title={view.title}
|
|
/>
|
|
</span>
|
|
</div>
|
|
<MessageTimestamp
|
|
i18n={i18n}
|
|
module="StoryViewsNRepliesModal__view--timestamp"
|
|
timestamp={view.timestamp}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : undefined;
|
|
|
|
const tabsElement =
|
|
views.length && replies.length ? (
|
|
<Tabs
|
|
initialSelectedTab={Tab.Views}
|
|
moduleClassName="StoryViewsNRepliesModal__tabs"
|
|
tabs={[
|
|
{
|
|
id: Tab.Views,
|
|
label: i18n('StoryViewsNRepliesModal__tab--views'),
|
|
},
|
|
{
|
|
id: Tab.Replies,
|
|
label: i18n('StoryViewsNRepliesModal__tab--replies'),
|
|
},
|
|
]}
|
|
>
|
|
{({ selectedTab }) => (
|
|
<>
|
|
{selectedTab === Tab.Views && viewsElement}
|
|
{selectedTab === Tab.Replies && (
|
|
<>
|
|
{repliesElement}
|
|
{composerElement}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</Tabs>
|
|
) : undefined;
|
|
|
|
return (
|
|
<Modal
|
|
i18n={i18n}
|
|
moduleClassName="StoryViewsNRepliesModal"
|
|
onClose={onClose}
|
|
useFocusTrap={Boolean(composerElement)}
|
|
theme={Theme.Dark}
|
|
>
|
|
<div
|
|
className={classNames({
|
|
'StoryViewsNRepliesModal--group': Boolean(isGroupStory),
|
|
})}
|
|
>
|
|
{tabsElement || (
|
|
<>
|
|
{viewsElement || repliesElement}
|
|
{composerElement}
|
|
</>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|