Render group stories

This commit is contained in:
Josh Perez 2022-04-14 20:08:46 -04:00 committed by GitHub
parent 14ab7b9e0d
commit e3d537cbd3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 527 additions and 163 deletions

View file

@ -86,11 +86,15 @@ export const ModalHost = React.memo(
<animated.div
role="presentation"
className={getClassName('__overlay')}
onMouseDown={noMouseClose ? undefined : handleMouseDown}
onMouseUp={noMouseClose ? undefined : handleMouseUp}
style={overlayStyles}
/>
<div className={getClassName('__overlay-container')}>{children}</div>
<div
className={getClassName('__overlay-container')}
onMouseDown={noMouseClose ? undefined : handleMouseDown}
onMouseUp={noMouseClose ? undefined : handleMouseUp}
>
{children}
</div>
</div>
);

View file

@ -7,6 +7,7 @@ import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import type { AttachmentType } from '../types/Attachment';
import type { ConversationType } from '../state/ducks/conversations';
import type { PropsType } from './Stories';
import { Stories } from './Stories';
import enMessages from '../../_locales/en/messages.json';
@ -28,7 +29,17 @@ function createStory({
timestamp,
}: {
attachment?: AttachmentType;
group?: { title: string };
group?: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'id'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
>;
timestamp: number;
}) {
const replies = Math.random() > 0.5;
@ -87,7 +98,7 @@ const getDefaultProps = (): PropsType => ({
timestamp: Date.now() - 5 * durations.MINUTE,
}),
createStory({
group: { title: 'BBQ in the park' },
group: getDefaultConversation({ title: 'BBQ in the park' }),
attachment: getAttachmentWithThumbnail(
'/fixtures/nathan-anderson-316188-unsplash.jpg'
),
@ -102,7 +113,7 @@ const getDefaultProps = (): PropsType => ({
timestamp: Date.now() - 164 * durations.MINUTE,
}),
createStory({
group: { title: 'Breaking Signal for Science' },
group: getDefaultConversation({ title: 'Breaking Signal for Science' }),
attachment: getAttachmentWithThumbnail('/fixtures/kitten-2-64-64.jpg'),
timestamp: Date.now() - 380 * durations.MINUTE,
}),

View file

@ -4,7 +4,7 @@
import FocusTrap from 'focus-trap-react';
import React, { useState } from 'react';
import classNames from 'classnames';
import type { ConversationStoryType, StoryViewType } from './StoryListItem';
import type { ConversationStoryType } from './StoryListItem';
import type { LocalizerType } from '../types/Util';
import type { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer';
import { StoriesPane } from './StoriesPane';
@ -23,11 +23,6 @@ export type PropsType = {
toggleStoriesView: () => unknown;
};
type ViewingStoryType = {
conversationId: string;
stories: Array<StoryViewType>;
};
export const Stories = ({
hiddenStories,
i18n,
@ -39,8 +34,8 @@ export const Stories = ({
toggleHideStories,
toggleStoriesView,
}: PropsType): JSX.Element => {
const [storiesToView, setStoriesToView] = useState<
undefined | ViewingStoryType
const [conversationIdToView, setConversationIdToView] = useState<
undefined | string
>();
const width = getWidthFromPreferredWidth(preferredWidthFromStorage, {
@ -49,42 +44,35 @@ export const Stories = ({
return (
<div className={classNames('Stories', themeClassName(Theme.Dark))}>
{storiesToView &&
{conversationIdToView &&
renderStoryViewer({
conversationId: storiesToView.conversationId,
onClose: () => setStoriesToView(undefined),
conversationId: conversationIdToView,
onClose: () => setConversationIdToView(undefined),
onNextUserStories: () => {
const storyIndex = stories.findIndex(
x => x.conversationId === storiesToView.conversationId
x => x.conversationId === conversationIdToView
);
if (storyIndex >= stories.length - 1) {
setStoriesToView(undefined);
setConversationIdToView(undefined);
return;
}
const nextStory = stories[storyIndex + 1];
setStoriesToView({
conversationId: nextStory.conversationId,
stories: nextStory.stories,
});
setConversationIdToView(nextStory.conversationId);
},
onPrevUserStories: () => {
const storyIndex = stories.findIndex(
x => x.conversationId === storiesToView.conversationId
x => x.conversationId === conversationIdToView
);
if (storyIndex === 0) {
setStoriesToView(undefined);
setConversationIdToView(undefined);
return;
}
const prevStory = stories[storyIndex - 1];
setStoriesToView({
conversationId: prevStory.conversationId,
stories: prevStory.stories,
});
setConversationIdToView(prevStory.conversationId);
},
stories: storiesToView.stories,
})}
<div className="Stories__pane" style={{ width }}>
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
<div className="Stories__pane" style={{ width }}>
<StoriesPane
hiddenStories={hiddenStories}
i18n={i18n}
@ -96,10 +84,7 @@ export const Stories = ({
const foundStory = stories[storyIndex];
if (foundStory) {
setStoriesToView({
conversationId,
stories: foundStory.stories,
});
setConversationIdToView(conversationId);
}
}}
openConversationInternal={openConversationInternal}
@ -107,8 +92,8 @@ export const Stories = ({
stories={stories}
toggleHideStories={toggleHideStories}
/>
</FocusTrap>
</div>
</div>
</FocusTrap>
<div className="Stories__placeholder">
<div className="Stories__placeholder__stories" />
{i18n('Stories__placeholder--text')}

View file

@ -111,8 +111,9 @@ export const StoriesPane = ({
>
{renderedStories.map(story => (
<StoryListItem
key={getNewestStory(story).timestamp}
group={story.group}
i18n={i18n}
key={getNewestStory(story).timestamp}
onClick={() => {
onStoryClicked(story.conversationId);
}}

View file

@ -63,7 +63,7 @@ story.add('My Story (many)', () => (
story.add("Someone's story", () => (
<StoryListItem
{...getDefaultProps()}
group={{ title: 'Sports Group' }}
group={getDefaultConversation({ title: 'Sports Group' })}
story={{
attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/tina-rolf-269345-unsplash.jpg'),

View file

@ -15,7 +15,17 @@ import { getAvatarColor } from '../types/Colors';
export type ConversationStoryType = {
conversationId: string;
group?: Pick<ConversationType, 'title'>;
group?: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'id'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
>;
hasMultiple?: boolean;
isHidden?: boolean;
searchNames?: string; // This is just here to satisfy Fuse's types
@ -24,6 +34,7 @@ export type ConversationStoryType = {
export type StoryViewType = {
attachment?: AttachmentType;
canReply?: boolean;
hasReplies?: boolean;
hasRepliesFromSelf?: boolean;
isHidden?: boolean;

View file

@ -17,10 +17,14 @@ const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/StoryViewer', module);
function getDefaultProps(): PropsType {
const sender = getDefaultConversation();
return {
conversationId: sender.id,
getPreferredBadge: () => undefined,
group: undefined,
i18n,
loadStoryReplies: action('loadStoryReplies'),
markStoryRead: action('markStoryRead'),
onClose: action('onClose'),
onNextUserStories: action('onNextUserStories'),
@ -33,18 +37,16 @@ function getDefaultProps(): PropsType {
preferredReactionEmoji: ['❤️', '👍', '👎', '😂', '😮', '😢'],
queueStoryDownload: action('queueStoryDownload'),
renderEmojiPicker: () => <div />,
replies: Math.floor(Math.random() * 20),
stories: [
{
attachment: fakeAttachment({
url: '/fixtures/snow.jpg',
}),
messageId: '123',
sender: getDefaultConversation(),
sender,
timestamp: Date.now(),
},
],
views: Math.floor(Math.random() * 20),
};
}

View file

@ -9,6 +9,7 @@ import type { ConversationType } from '../state/ducks/conversations';
import type { EmojiPickDataType } from './emoji/EmojiPicker';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
import type { ReplyStateType } from '../types/Stories';
import type { StoryViewType } from './StoryListItem';
import { Avatar, AvatarSize } from './Avatar';
import { Intl } from './Intl';
@ -22,9 +23,21 @@ import { isDownloaded, isDownloading } from '../types/Attachment';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
export type PropsType = {
conversationId: string;
getPreferredBadge: PreferredBadgeSelectorType;
group?: ConversationType;
group?: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'id'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
>;
i18n: LocalizerType;
loadStoryReplies: (conversationId: string, messageId: string) => unknown;
markStoryRead: (mId: string) => unknown;
onClose: () => unknown;
onNextUserStories: () => unknown;
@ -42,11 +55,10 @@ export type PropsType = {
preferredReactionEmoji: Array<string>;
queueStoryDownload: (storyId: string) => unknown;
recentEmojis?: Array<string>;
replies?: number;
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
replyState?: ReplyStateType;
skinTone?: number;
stories: Array<StoryViewType>;
views?: number;
};
const CAPTION_BUFFER = 20;
@ -54,9 +66,11 @@ const CAPTION_INITIAL_LENGTH = 200;
const CAPTION_MAX_LENGTH = 700;
export const StoryViewer = ({
conversationId,
getPreferredBadge,
group,
i18n,
loadStoryReplies,
markStoryRead,
onClose,
onNextUserStories,
@ -70,17 +84,16 @@ export const StoryViewer = ({
queueStoryDownload,
recentEmojis,
renderEmojiPicker,
replies,
replyState,
skinTone,
stories,
views,
}: PropsType): JSX.Element => {
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
const [storyDuration, setStoryDuration] = useState<number | undefined>();
const visibleStory = stories[currentStoryIndex];
const { attachment, messageId, timestamp } = visibleStory;
const { attachment, canReply, messageId, timestamp } = visibleStory;
const {
acceptedMessageRequest,
avatarPath,
@ -240,6 +253,20 @@ export const StoryViewer = ({
};
}, [navigateStories]);
const isGroupStory = Boolean(group?.id);
useEffect(() => {
if (!isGroupStory) {
return;
}
loadStoryReplies(conversationId, messageId);
}, [conversationId, isGroupStory, loadStoryReplies, messageId]);
const replies =
replyState && replyState.messageId === messageId ? replyState.replies : [];
const viewCount = 0;
const replyCount = replies.length;
return (
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
<div className="StoryViewer">
@ -366,49 +393,51 @@ export const StoryViewer = ({
<div className="StoryViewer__actions">
{isMe ? (
<>
{views &&
(views === 1 ? (
{viewCount &&
(viewCount === 1 ? (
<Intl
i18n={i18n}
id="MyStories__views--singular"
components={[<strong>{views}</strong>]}
components={[<strong>{viewCount}</strong>]}
/>
) : (
<Intl
i18n={i18n}
id="MyStories__views--plural"
components={[<strong>{views}</strong>]}
components={[<strong>{viewCount}</strong>]}
/>
))}
{views && replies && ' '}
{replies &&
(replies === 1 ? (
{viewCount && replyCount && ' '}
{replyCount &&
(replyCount === 1 ? (
<Intl
i18n={i18n}
id="MyStories__replies--singular"
components={[<strong>{replies}</strong>]}
components={[<strong>{replyCount}</strong>]}
/>
) : (
<Intl
i18n={i18n}
id="MyStories__replies--plural"
components={[<strong>{replies}</strong>]}
components={[<strong>{replyCount}</strong>]}
/>
))}
</>
) : (
<button
className="StoryViewer__reply"
onClick={() => setHasReplyModal(true)}
tabIndex={0}
type="button"
>
{i18n('StoryViewer__reply')}
</button>
canReply && (
<button
className="StoryViewer__reply"
onClick={() => setHasReplyModal(true)}
tabIndex={0}
type="button"
>
{i18n('StoryViewer__reply')}
</button>
)
)}
</div>
</div>
{hasReplyModal && (
{hasReplyModal && canReply && (
<StoryViewsNRepliesModal
authorTitle={title}
getPreferredBadge={getPreferredBadge}
@ -428,7 +457,7 @@ export const StoryViewer = ({
preferredReactionEmoji={preferredReactionEmoji}
recentEmojis={recentEmojis}
renderEmojiPicker={renderEmojiPicker}
replies={[]}
replies={replies}
skinTone={skinTone}
storyPreviewAttachment={attachment}
views={[]}

View file

@ -12,6 +12,7 @@ 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';
@ -23,26 +24,10 @@ 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 ReplyType = Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'isMe'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
> & {
body?: string;
contactNameColor?: ContactNameColorType;
reactionEmoji?: string;
timestamp: number;
};
type ViewType = Pick<
ConversationType,
| 'acceptedMessageRequest'
@ -223,7 +208,7 @@ export const StoryViewsNRepliesModal = ({
<div className="StoryViewsNRepliesModal__replies">
{replies.map(reply =>
reply.reactionEmoji ? (
<div className="StoryViewsNRepliesModal__reaction">
<div className="StoryViewsNRepliesModal__reaction" key={reply.id}>
<div className="StoryViewsNRepliesModal__reaction--container">
<Avatar
acceptedMessageRequest={reply.acceptedMessageRequest}
@ -257,7 +242,7 @@ export const StoryViewsNRepliesModal = ({
<Emojify text={reply.reactionEmoji} />
</div>
) : (
<div className="StoryViewsNRepliesModal__reply">
<div className="StoryViewsNRepliesModal__reply" key={reply.id}>
<Avatar
acceptedMessageRequest={reply.acceptedMessageRequest}
avatarPath={reply.avatarPath}
@ -272,7 +257,13 @@ export const StoryViewsNRepliesModal = ({
size={AvatarSize.TWENTY_EIGHT}
title={reply.title}
/>
<div className="StoryViewsNRepliesModal__message-bubble">
<div
className={classNames('StoryViewsNRepliesModal__message-bubble', {
'StoryViewsNRepliesModal__message-bubble--doe': Boolean(
reply.deletedForEveryone
),
})}
>
<div className="StoryViewsNRepliesModal__reply--title">
<ContactName
contactNameColor={reply.contactNameColor}
@ -280,7 +271,14 @@ export const StoryViewsNRepliesModal = ({
/>
</div>
<MessageBody i18n={i18n} text={String(reply.body)} />
<MessageBody
i18n={i18n}
text={
reply.deletedForEveryone
? i18n('message--deletedForEveryone')
: String(reply.body)
}
/>
<MessageTimestamp
i18n={i18n}
@ -373,6 +371,7 @@ export const StoryViewsNRepliesModal = ({
})}
onClose={onClose}
useFocusTrap={!hasOnlyViewsElement}
theme={Theme.Dark}
>
{tabsElement || (
<>

View file

@ -47,7 +47,7 @@ type State = {
export type QuotedAttachmentType = Pick<
AttachmentType,
'contentType' | 'fileName' | 'isVoiceMessage' | 'thumbnail'
'contentType' | 'fileName' | 'isVoiceMessage' | 'thumbnail' | 'textAttachment'
>;
function validateQuote(quote: Props): boolean {
@ -221,10 +221,11 @@ export class Quote extends React.Component<Props, State> {
return null;
}
const { fileName, contentType } = attachment;
const { fileName, contentType, textAttachment } = attachment;
const isGenericFile =
!GoogleChrome.isVideoTypeSupported(contentType) &&
!GoogleChrome.isImageTypeSupported(contentType) &&
!textAttachment &&
!MIME.isAudio(contentType);
if (!isGenericFile) {
@ -257,13 +258,18 @@ export class Quote extends React.Component<Props, State> {
return null;
}
const { contentType, thumbnail } = attachment;
const { contentType, textAttachment, thumbnail } = attachment;
const url = getUrl(thumbnail);
if (isViewOnce) {
return this.renderIcon('view-once');
}
// TODO DESKTOP-3433
if (textAttachment) {
return this.renderIcon('image');
}
if (GoogleChrome.isVideoTypeSupported(contentType)) {
return url && !imageBroken
? this.renderImage(url, 'play')