diff --git a/stylesheets/components/StoryViewer.scss b/stylesheets/components/StoryViewer.scss index 710a46990..781e2165b 100644 --- a/stylesheets/components/StoryViewer.scss +++ b/stylesheets/components/StoryViewer.scss @@ -107,11 +107,16 @@ } &__actions { - margin: 16px 0 32px 0; + align-items: center; + display: flex; + justify-content: center; + margin-bottom: 32px; + min-height: 52px; } &__reply { @include button-reset; + color: $color-gray-05; @include keyboard-mode { &:focus { color: $color-ultramarine; diff --git a/stylesheets/components/StoryViewsNRepliesModal.scss b/stylesheets/components/StoryViewsNRepliesModal.scss index 9e3c53f5e..a7989d385 100644 --- a/stylesheets/components/StoryViewsNRepliesModal.scss +++ b/stylesheets/components/StoryViewsNRepliesModal.scss @@ -125,6 +125,11 @@ border-radius: 18px; margin-left: 8px; padding: 7px 12px; + + &--doe { + background: none; + border: 1px solid $color-gray-75; + } } &__quote { diff --git a/stylesheets/components/TextAttachment.scss b/stylesheets/components/TextAttachment.scss index 8123ae16d..a4f48bc22 100644 --- a/stylesheets/components/TextAttachment.scss +++ b/stylesheets/components/TextAttachment.scss @@ -73,6 +73,7 @@ &__title { align-items: flex-start; + color: $color-gray-05; display: flex; flex-direction: column; justify-content: flex-start; diff --git a/ts/components/ModalHost.tsx b/ts/components/ModalHost.tsx index 5dc4d0b9f..cb8151100 100644 --- a/ts/components/ModalHost.tsx +++ b/ts/components/ModalHost.tsx @@ -86,11 +86,15 @@ export const ModalHost = React.memo( -
{children}
+
+ {children} +
); diff --git a/ts/components/Stories.stories.tsx b/ts/components/Stories.stories.tsx index 62b50c650..2acf5ac22 100644 --- a/ts/components/Stories.stories.tsx +++ b/ts/components/Stories.stories.tsx @@ -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, }), diff --git a/ts/components/Stories.tsx b/ts/components/Stories.tsx index 7146b5c90..633c88c11 100644 --- a/ts/components/Stories.tsx +++ b/ts/components/Stories.tsx @@ -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; -}; - 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 (
- {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, })} -
- + +
- -
+
+
{i18n('Stories__placeholder--text')} diff --git a/ts/components/StoriesPane.tsx b/ts/components/StoriesPane.tsx index e082959bd..674cce8c7 100644 --- a/ts/components/StoriesPane.tsx +++ b/ts/components/StoriesPane.tsx @@ -111,8 +111,9 @@ export const StoriesPane = ({ > {renderedStories.map(story => ( { onStoryClicked(story.conversationId); }} diff --git a/ts/components/StoryListItem.stories.tsx b/ts/components/StoryListItem.stories.tsx index eff359e24..91513960a 100644 --- a/ts/components/StoryListItem.stories.tsx +++ b/ts/components/StoryListItem.stories.tsx @@ -63,7 +63,7 @@ story.add('My Story (many)', () => ( story.add("Someone's story", () => ( ; + 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; diff --git a/ts/components/StoryViewer.stories.tsx b/ts/components/StoryViewer.stories.tsx index 64610153d..0983ae048 100644 --- a/ts/components/StoryViewer.stories.tsx +++ b/ts/components/StoryViewer.stories.tsx @@ -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: () =>
, - 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), }; } diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index 9a1128d82..bbb2be272 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -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; queueStoryDownload: (storyId: string) => unknown; recentEmojis?: Array; - replies?: number; renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element; + replyState?: ReplyStateType; skinTone?: number; stories: Array; - 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(); 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 (
@@ -366,49 +393,51 @@ export const StoryViewer = ({
{isMe ? ( <> - {views && - (views === 1 ? ( + {viewCount && + (viewCount === 1 ? ( {views}]} + components={[{viewCount}]} /> ) : ( {views}]} + components={[{viewCount}]} /> ))} - {views && replies && ' '} - {replies && - (replies === 1 ? ( + {viewCount && replyCount && ' '} + {replyCount && + (replyCount === 1 ? ( {replies}]} + components={[{replyCount}]} /> ) : ( {replies}]} + components={[{replyCount}]} /> ))} ) : ( - + canReply && ( + + ) )}
- {hasReplyModal && ( + {hasReplyModal && canReply && ( & { - body?: string; - contactNameColor?: ContactNameColorType; - reactionEmoji?: string; - timestamp: number; -}; - type ViewType = Pick< ConversationType, | 'acceptedMessageRequest' @@ -223,7 +208,7 @@ export const StoryViewsNRepliesModal = ({
{replies.map(reply => reply.reactionEmoji ? ( -
+
) : ( -
+
-
+
- + {tabsElement || ( <> diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx index 8c626a897..5581d46af 100644 --- a/ts/components/conversation/Quote.tsx +++ b/ts/components/conversation/Quote.tsx @@ -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 { 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 { 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') diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index b4f6b6ead..3a3282faa 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -3958,7 +3958,7 @@ export class ConversationModel extends window.Backbone storyId?: string; timestamp?: number; } = {} - ): Promise { + ): Promise { if (this.isGroupV1AndDisabled()) { return; } @@ -4143,6 +4143,8 @@ export class ConversationModel extends window.Backbone } window.Signal.Data.updateConversation(this.attributes); + + return attributes; } // Is this someone who is a contact, or are we sharing our profile with them? diff --git a/ts/services/storyLoader.ts b/ts/services/storyLoader.ts index fdc5e3e71..a6c473568 100644 --- a/ts/services/storyLoader.ts +++ b/ts/services/storyLoader.ts @@ -43,10 +43,13 @@ export function getStoryDataFromMessageAttributes( selectedReaction, ...pick(message, [ 'conversationId', + 'deletedForEveryone', 'readStatus', + 'sendStateByConversationId', 'source', 'sourceUuid', 'timestamp', + 'type', ]), }; } diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index c320d3b03..3f6876de6 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -1238,7 +1238,7 @@ async function getOlderMessagesByConversation( messageId?: string; receivedAt?: number; sentAt?: number; - storyId?: UUIDStringType; + storyId?: string; } ) { const messages = await channels.getOlderMessagesByConversation( diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index e4660e016..c61d454f1 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -617,7 +617,7 @@ export type ServerInterface = DataInterface & { messageId?: string; receivedAt?: number; sentAt?: number; - storyId?: UUIDStringType; + storyId?: string; } ) => Promise>; getNewerMessagesByConversation: ( @@ -687,7 +687,7 @@ export type ClientInterface = DataInterface & { messageId?: string; receivedAt?: number; sentAt?: number; - storyId?: UUIDStringType; + storyId?: string; } ) => Promise>; getNewerMessagesByConversation: ( diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 4fc1f913d..bf475fd05 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -2314,7 +2314,7 @@ async function getOlderMessagesByConversation( messageId?: string; receivedAt?: number; sentAt?: number; - storyId?: UUIDStringType; + storyId?: string; } ): Promise> { return getOlderMessagesByConversationSync(conversationId, options); @@ -2332,7 +2332,7 @@ function getOlderMessagesByConversationSync( messageId?: string; receivedAt?: number; sentAt?: number; - storyId?: UUIDStringType; + storyId?: string; } = {} ): Array { const db = getInstance(); diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index 8bd7c5bc7..1c2f44c8a 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -6,7 +6,10 @@ import { pick } from 'lodash'; import type { AttachmentType } from '../../types/Attachment'; import type { BodyRangeType } from '../../types/Util'; import type { MessageAttributesType } from '../../model-types.d'; -import type { MessageDeletedActionType } from './conversations'; +import type { + MessageChangedActionType, + MessageDeletedActionType, +} from './conversations'; import type { NoopActionType } from './noop'; import type { StateType as RootStateType } from '../reducer'; import type { StoryViewType } from '../../components/StoryListItem'; @@ -33,23 +36,44 @@ export type StoryDataType = { selectedReaction?: string; } & Pick< MessageAttributesType, - 'conversationId' | 'readStatus' | 'source' | 'sourceUuid' | 'timestamp' + | 'conversationId' + | 'deletedForEveryone' + | 'readStatus' + | 'sendStateByConversationId' + | 'source' + | 'sourceUuid' + | 'timestamp' + | 'type' >; // State export type StoriesStateType = { readonly isShowingStoriesView: boolean; + readonly replyState?: { + messageId: string; + replies: Array; + }; readonly stories: Array; }; // Actions +const LOAD_STORY_REPLIES = 'stories/LOAD_STORY_REPLIES'; const MARK_STORY_READ = 'stories/MARK_STORY_READ'; const REACT_TO_STORY = 'stories/REACT_TO_STORY'; +const REPLY_TO_STORY = 'stories/REPLY_TO_STORY'; const STORY_CHANGED = 'stories/STORY_CHANGED'; const TOGGLE_VIEW = 'stories/TOGGLE_VIEW'; +type LoadStoryRepliesActionType = { + type: typeof LOAD_STORY_REPLIES; + payload: { + messageId: string; + replies: Array; + }; +}; + type MarkStoryReadActionType = { type: typeof MARK_STORY_READ; payload: string; @@ -63,6 +87,11 @@ type ReactToStoryActionType = { }; }; +type ReplyToStoryActionType = { + type: typeof REPLY_TO_STORY; + payload: MessageAttributesType; +}; + type StoryChangedActionType = { type: typeof STORY_CHANGED; payload: StoryDataType; @@ -73,15 +102,19 @@ type ToggleViewActionType = { }; export type StoriesActionType = + | LoadStoryRepliesActionType | MarkStoryReadActionType + | MessageChangedActionType | MessageDeletedActionType | ReactToStoryActionType + | ReplyToStoryActionType | StoryChangedActionType | ToggleViewActionType; // Action Creators export const actions = { + loadStoryReplies, markStoryRead, queueStoryDownload, reactToStory, @@ -92,6 +125,26 @@ export const actions = { export const useStoriesActions = (): typeof actions => useBoundActions(actions); +function loadStoryReplies( + conversationId: string, + messageId: string +): ThunkAction { + return async dispatch => { + const replies = await dataInterface.getOlderMessagesByConversation( + conversationId, + { limit: 9000, storyId: messageId } + ); + + dispatch({ + type: LOAD_STORY_REPLIES, + payload: { + messageId, + replies, + }, + }); + }; +} + function markStoryRead( messageId: string ): ThunkAction { @@ -225,11 +278,16 @@ function replyToStory( mentions: Array, timestamp: number, story: StoryViewType -): NoopActionType { - const conversation = window.ConversationController.get(conversationId); +): ThunkAction { + return async dispatch => { + const conversation = window.ConversationController.get(conversationId); - if (conversation) { - conversation.enqueueMessageForSend( + if (!conversation) { + log.error('replyToStory: conversation does not exist', conversationId); + return; + } + + const messageAttributes = await conversation.enqueueMessageForSend( { body: messageBody, attachments: [], @@ -240,11 +298,13 @@ function replyToStory( timestamp, } ); - } - return { - type: 'NOOP', - payload: null, + if (messageAttributes) { + dispatch({ + type: REPLY_TO_STORY, + payload: messageAttributes, + }); + } }; } @@ -285,11 +345,17 @@ export function reducer( } if (action.type === 'MESSAGE_DELETED') { + const nextStories = state.stories.filter( + story => story.messageId !== action.payload.id + ); + + if (nextStories.length === state.stories.length) { + return state; + } + return { ...state, - stories: state.stories.filter( - story => story.messageId !== action.payload.id - ), + stories: nextStories, }; } @@ -297,12 +363,15 @@ export function reducer( const newStory = pick(action.payload, [ 'attachment', 'conversationId', + 'deletedForEveryone', 'messageId', 'readStatus', 'selectedReaction', + 'sendStateByConversationId', 'source', 'sourceUuid', 'timestamp', + 'type', ]); // Stories don't really need to change except for when we don't have the @@ -326,6 +395,10 @@ export function reducer( existingStory => existingStory.messageId === newStory.messageId ); + if (storyIndex < 0) { + return state; + } + return { ...state, stories: replaceIndex(state.stories, storyIndex, newStory), @@ -374,5 +447,63 @@ export function reducer( }; } + if (action.type === LOAD_STORY_REPLIES) { + return { + ...state, + replyState: action.payload, + }; + } + + // For live updating of the story replies + if ( + action.type === 'MESSAGE_CHANGED' && + state.replyState && + state.replyState.messageId === action.payload.data.storyId + ) { + const { replyState } = state; + const messageIndex = replyState.replies.findIndex( + reply => reply.id === action.payload.id + ); + + // New message + if (messageIndex < 0) { + return { + ...state, + replyState: { + messageId: replyState.messageId, + replies: [...replyState.replies, action.payload.data], + }, + }; + } + + // Changed message, also handles DOE + return { + ...state, + replyState: { + messageId: replyState.messageId, + replies: replaceIndex( + replyState.replies, + messageIndex, + action.payload.data + ), + }, + }; + } + + if (action.type === REPLY_TO_STORY) { + const { replyState } = state; + if (!replyState) { + return state; + } + + return { + ...state, + replyState: { + messageId: replyState.messageId, + replies: [...replyState.replies, action.payload], + }, + }; + } + return state; } diff --git a/ts/state/selectors/stories.ts b/ts/state/selectors/stories.ts index c167aad2d..974dbdf36 100644 --- a/ts/state/selectors/stories.ts +++ b/ts/state/selectors/stories.ts @@ -4,14 +4,22 @@ import { createSelector } from 'reselect'; import { pick } from 'lodash'; +import type { GetConversationByIdType } from './conversations'; import type { ConversationStoryType, StoryViewType, } from '../../components/StoryListItem'; +import type { ReplyStateType } from '../../types/Stories'; import type { StateType } from '../reducer'; -import type { StoriesStateType } from '../ducks/stories'; +import type { StoryDataType, StoriesStateType } from '../ducks/stories'; import { ReadStatus } from '../../messages/MessageReadStatus'; -import { getConversationSelector } from './conversations'; +import { canReply } from './message'; +import { + getContactNameColorSelector, + getConversationSelector, + getMe, +} from './conversations'; +import { getUserConversationId } from './user'; export const getStoriesState = (state: StateType): StoriesStateType => state.stories; @@ -47,12 +55,148 @@ function sortByRecencyAndUnread( return storyA.timestamp > storyB.timestamp ? -1 : 1; } +function getConversationStory( + conversationSelector: GetConversationByIdType, + story: StoryDataType, + ourConversationId?: string +): ConversationStoryType { + const sender = pick(conversationSelector(story.sourceUuid || story.source), [ + 'acceptedMessageRequest', + 'avatarPath', + 'color', + 'firstName', + 'hideStory', + 'id', + 'isMe', + 'name', + 'profileName', + 'sharedGroupNames', + 'title', + ]); + + const conversation = pick(conversationSelector(story.conversationId), [ + 'acceptedMessageRequest', + 'avatarPath', + 'color', + 'id', + 'name', + 'profileName', + 'sharedGroupNames', + 'title', + ]); + + const { attachment, timestamp } = pick(story, ['attachment', 'timestamp']); + + const storyView: StoryViewType = { + attachment, + canReply: canReply(story, ourConversationId, conversationSelector), + isUnread: story.readStatus === ReadStatus.Unread, + messageId: story.messageId, + selectedReaction: story.selectedReaction, + sender, + timestamp, + }; + + return { + conversationId: conversation.id, + group: conversation.id !== sender.id ? conversation : undefined, + isHidden: Boolean(sender.hideStory), + stories: [storyView], + }; +} + +export type GetStoriesByConversationIdType = ( + conversationId: string +) => ConversationStoryType; +export const getStoriesSelector = createSelector( + getConversationSelector, + getUserConversationId, + getStoriesState, + ( + conversationSelector, + ourConversationId, + { stories }: Readonly + ): GetStoriesByConversationIdType => { + return conversationId => { + const conversationStoryAcc: ConversationStoryType = { + conversationId, + stories: [], + }; + + return stories.reduce((acc, story) => { + if (story.conversationId !== conversationId) { + return acc; + } + + const conversationStory = getConversationStory( + conversationSelector, + story, + ourConversationId + ); + + return { + ...acc, + ...conversationStory, + stories: [...acc.stories, ...conversationStory.stories], + }; + }, conversationStoryAcc); + }; + } +); + +export const getStoryReplies = createSelector( + getConversationSelector, + getContactNameColorSelector, + getMe, + getStoriesState, + ( + conversationSelector, + contactNameColorSelector, + me, + { replyState }: Readonly + ): ReplyStateType | undefined => { + if (!replyState) { + return; + } + + return { + messageId: replyState.messageId, + replies: replyState.replies.map(reply => { + const conversation = + reply.type === 'outgoing' + ? me + : conversationSelector(reply.sourceUuid || reply.source); + + return { + ...pick(conversation, [ + 'acceptedMessageRequest', + 'avatarPath', + 'color', + 'isMe', + 'name', + 'profileName', + 'sharedGroupNames', + 'title', + ]), + ...pick(reply, ['body', 'deletedForEveryone', 'id', 'timestamp']), + contactNameColor: contactNameColorSelector( + reply.conversationId, + conversation.id + ), + }; + }), + }; + } +); + export const getStories = createSelector( getConversationSelector, + getUserConversationId, getStoriesState, shouldShowStoriesView, ( conversationSelector, + ourConversationId, { stories }: Readonly, isShowingStoriesView ): { @@ -70,58 +214,30 @@ export const getStories = createSelector( const hiddenStoriesById = new Map(); stories.forEach(story => { - const sender = pick( - conversationSelector(story.sourceUuid || story.source), - [ - 'acceptedMessageRequest', - 'avatarPath', - 'color', - 'firstName', - 'hideStory', - 'id', - 'isMe', - 'name', - 'profileName', - 'sharedGroupNames', - 'title', - ] + const conversationStory = getConversationStory( + conversationSelector, + story, + ourConversationId ); - const conversation = pick(conversationSelector(story.conversationId), [ - 'id', - 'title', - ]); - - const { attachment, timestamp } = pick(story, [ - 'attachment', - 'timestamp', - ]); - let storiesMap: Map; - if (sender.hideStory) { + if (conversationStory.isHidden) { storiesMap = hiddenStoriesById; } else { storiesMap = storiesById; } - const storyView: StoryViewType = { - attachment, - isUnread: story.readStatus === ReadStatus.Unread, - messageId: story.messageId, - selectedReaction: story.selectedReaction, - sender, - timestamp, - }; + const existingConversationStory = storiesMap.get( + conversationStory.conversationId + ) || { stories: [] }; - const conversationStory = storiesMap.get(conversation.id) || { - conversationId: conversation.id, - group: conversation.id !== sender.id ? conversation : undefined, - isHidden: Boolean(sender.hideStory), - stories: [], - }; - storiesMap.set(conversation.id, { + storiesMap.set(conversationStory.conversationId, { + ...existingConversationStory, ...conversationStory, - stories: [...conversationStory.stories, storyView], + stories: [ + ...existingConversationStory.stories, + ...conversationStory.stories, + ], }); }); diff --git a/ts/state/smart/Stories.tsx b/ts/state/smart/Stories.tsx index 3d3087f58..f451929be 100644 --- a/ts/state/smart/Stories.tsx +++ b/ts/state/smart/Stories.tsx @@ -20,7 +20,6 @@ function renderStoryViewer({ onClose, onNextUserStories, onPrevUserStories, - stories, }: SmartStoryViewerPropsType): JSX.Element { return ( ); } diff --git a/ts/state/smart/StoryViewer.tsx b/ts/state/smart/StoryViewer.tsx index 5ea125272..06afed896 100644 --- a/ts/state/smart/StoryViewer.tsx +++ b/ts/state/smart/StoryViewer.tsx @@ -4,15 +4,16 @@ import React from 'react'; import { useSelector } from 'react-redux'; +import type { GetStoriesByConversationIdType } from '../selectors/stories'; import type { LocalizerType } from '../../types/Util'; import type { StateType } from '../reducer'; -import type { StoryViewType } from '../../components/StoryListItem'; import { StoryViewer } from '../../components/StoryViewer'; import { ToastMessageBodyTooLong } from '../../components/ToastMessageBodyTooLong'; import { getEmojiSkinTone, getPreferredReactionEmoji, } from '../selectors/items'; +import { getStoriesSelector, getStoryReplies } from '../selectors/stories'; import { getIntl } from '../selectors/user'; import { getPreferredBadgeSelector } from '../selectors/badges'; import { renderEmojiPicker } from './renderEmojiPicker'; @@ -27,7 +28,6 @@ export type PropsType = { onClose: () => unknown; onNextUserStories: () => unknown; onPrevUserStories: () => unknown; - stories: Array; }; export function SmartStoryViewer({ @@ -35,7 +35,6 @@ export function SmartStoryViewer({ onClose, onNextUserStories, onPrevUserStories, - stories, }: PropsType): JSX.Element | null { const storiesActions = useStoriesActions(); const { onSetSkinTone } = useItemsActions(); @@ -47,12 +46,22 @@ export function SmartStoryViewer({ getPreferredReactionEmoji ); + const getStoriesByConversationId = useSelector< + StateType, + GetStoriesByConversationIdType + >(getStoriesSelector); + + const { group, stories } = getStoriesByConversationId(conversationId); + const recentEmojis = useRecentEmojis(); const skinTone = useSelector(getEmojiSkinTone); + const replyState = useSelector(getStoryReplies); return ( & { + body?: string; + contactNameColor?: ContactNameColorType; + deletedForEveryone?: boolean; + id: string; + reactionEmoji?: string; + timestamp: number; +}; + +export type ReplyStateType = { + messageId: string; + replies: Array; +};