// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ThunkAction, ThunkDispatch } from 'redux-thunk'; import { isEqual, pick } from 'lodash'; import type { ReadonlyDeep } from 'type-fest'; import * as Errors from '../../types/errors'; import type { AttachmentType } from '../../types/Attachment'; import type { DraftBodyRanges } from '../../types/BodyRange'; import type { MessageAttributesType } from '../../model-types.d'; import type { MessageChangedActionType, MessageDeletedActionType, MessagesAddedActionType, TargetedConversationChangedActionType, } from './conversations'; import type { NoopActionType } from './noop'; import type { StateType as RootStateType } from '../reducer'; import type { StoryViewTargetType, StoryViewType } from '../../types/Stories'; import type { SyncType } from '../../jobs/helpers/syncHelpers'; import type { StoryDistributionIdString } from '../../types/StoryDistributionId'; import type { ServiceIdString } from '../../types/ServiceId'; import { isAciString } from '../../util/isAciString'; import * as log from '../../logging/log'; import { TARGETED_CONVERSATION_CHANGED } from './conversations'; import { SIGNAL_ACI } from '../../types/SignalConversation'; import dataInterface from '../../sql/Client'; import { ReadStatus } from '../../messages/MessageReadStatus'; import { SendStatus } from '../../messages/MessageSendState'; import { SafetyNumberChangeSource } from '../../components/SafetyNumberChangeDialog'; import { StoryViewDirectionType, StoryViewModeType } from '../../types/Stories'; import { assertDev, strictAssert } from '../../util/assert'; import { drop } from '../../util/drop'; import { blockSendUntilConversationsAreVerified } from '../../util/blockSendUntilConversationsAreVerified'; import { deleteStoryForEveryone as doDeleteStoryForEveryone } from '../../util/deleteStoryForEveryone'; import { deleteGroupStoryReplyForEveryone as doDeleteGroupStoryReplyForEveryone } from '../../util/deleteGroupStoryReplyForEveryone'; import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend'; import { __DEPRECATED$getMessageById } from '../../messages/getMessageById'; import { markOnboardingStoryAsRead } from '../../util/markOnboardingStoryAsRead'; import { markViewed } from '../../services/MessageUpdater'; import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads'; import { replaceIndex } from '../../util/replaceIndex'; import type { DurationInSeconds } from '../../util/durations'; import { hasFailed, isDownloaded, isDownloading } from '../../types/Attachment'; import { getConversationSelector, getHideStoryConversationIds, } from '../selectors/conversations'; import { getStories, getStoryDownloadableAttachment, } from '../selectors/stories'; import { getStoryDataFromMessageAttributes } from '../../services/storyLoader'; import { isGroup } from '../../util/whatTypeOfConversation'; import { isNotNil } from '../../util/isNotNil'; import { isStory } from '../../messages/helpers'; import { sendStoryMessage as doSendStoryMessage } from '../../util/sendStoryMessage'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import { useBoundActions } from '../../hooks/useBoundActions'; import { verifyStoryListMembers as doVerifyStoryListMembers } from '../../util/verifyStoryListMembers'; import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue'; import { getOwn } from '../../util/getOwn'; import { SHOW_TOAST } from './toast'; import { ToastType } from '../../types/Toast'; import type { ShowToastActionType } from './toast'; import { conversationJobQueue, conversationQueueJobEnum, } from '../../jobs/conversationJobQueue'; import { ReceiptType } from '../../types/Receipt'; export type StoryDataType = ReadonlyDeep< { attachment?: AttachmentType; hasReplies?: boolean; hasRepliesFromSelf?: boolean; messageId: string; startedDownload?: boolean; } & Pick< MessageAttributesType, | 'bodyRanges' | 'canReplyToStory' | 'conversationId' | 'deletedForEveryone' | 'reactions' | 'readAt' | 'readStatus' | 'sendStateByConversationId' | 'source' | 'sourceServiceId' | 'storyDistributionListId' | 'timestamp' | 'type' | 'storyRecipientsVersion' > & { // don't want the fields to be optional as in MessageAttributesType expireTimer: DurationInSeconds | undefined; expirationStartTimestamp: number | undefined; sourceDevice: number; } >; export type SelectedStoryDataType = ReadonlyDeep<{ currentIndex: number; messageId: string; numStories: number; storyViewMode: StoryViewModeType; unviewedStoryConversationIdsSorted: Array; viewTarget?: StoryViewTargetType; }>; export type AddStoryData = ReadonlyDeep< | { type: 'Media'; file: File; sending?: boolean; } | { type: 'Text'; sending?: boolean; } | undefined >; // eslint-disable-next-line local-rules/type-alias-readonlydeep export type RecipientsByConversation = Record< string, // conversationId { serviceIds: Array; byDistributionId?: Record< StoryDistributionIdString, { serviceIds: Array; } >; } >; // State // eslint-disable-next-line local-rules/type-alias-readonlydeep export type StoriesStateType = Readonly<{ addStoryData: AddStoryData; hasAllStoriesUnmuted: boolean; lastOpenedAtTimestamp: number | undefined; replyState?: Readonly<{ messageId: string; replies: Array; }>; selectedStoryData?: SelectedStoryDataType; sendStoryModalData?: RecipientsByConversation; stories: ReadonlyArray; }>; // Actions const DOE_STORY = 'stories/DOE'; const LIST_MEMBERS_VERIFIED = 'stories/LIST_MEMBERS_VERIFIED'; const LOAD_STORY_REPLIES = 'stories/LOAD_STORY_REPLIES'; const MARK_STORY_READ = 'stories/MARK_STORY_READ'; const QUEUE_STORY_DOWNLOAD = 'stories/QUEUE_STORY_DOWNLOAD'; const SEND_STORY_MODAL_OPEN_STATE_CHANGED = 'stories/SEND_STORY_MODAL_OPEN_STATE_CHANGED'; const STORY_CHANGED = 'stories/STORY_CHANGED'; const CLEAR_STORIES_TAB_STATE = 'stories/CLEAR_STORIES_TAB_STATE'; const MARK_STORIES_TAB_VIEWED = 'stories/MARK_STORIES_TAB_VIEWED'; const VIEW_STORY = 'stories/VIEW_STORY'; const STORY_REPLY_DELETED = 'stories/STORY_REPLY_DELETED'; const REMOVE_ALL_STORIES = 'stories/REMOVE_ALL_STORIES'; const SET_ADD_STORY_DATA = 'stories/SET_ADD_STORY_DATA'; const SET_STORY_SENDING = 'stories/SET_STORY_SENDING'; const SET_HAS_ALL_STORIES_UNMUTED = 'stories/SET_HAS_ALL_STORIES_UNMUTED'; type DOEStoryActionType = ReadonlyDeep<{ type: typeof DOE_STORY; payload: string; }>; type ListMembersVerified = ReadonlyDeep<{ type: typeof LIST_MEMBERS_VERIFIED; payload: { conversationId: string; distributionId: StoryDistributionIdString | undefined; serviceIds: Array; }; }>; // eslint-disable-next-line local-rules/type-alias-readonlydeep type LoadStoryRepliesActionType = { type: typeof LOAD_STORY_REPLIES; payload: { messageId: string; replies: Array; }; }; type MarkStoryReadActionType = ReadonlyDeep<{ type: typeof MARK_STORY_READ; payload: { messageId: string; readAt: number; }; }>; type QueueStoryDownloadActionType = ReadonlyDeep<{ type: typeof QUEUE_STORY_DOWNLOAD; payload: string; }>; type SendStoryModalOpenStateChanged = ReadonlyDeep<{ type: typeof SEND_STORY_MODAL_OPEN_STATE_CHANGED; payload: number | undefined; }>; type StoryChangedActionType = ReadonlyDeep<{ type: typeof STORY_CHANGED; payload: StoryDataType; }>; type ClearStoriesTabStateActionType = ReadonlyDeep<{ type: typeof CLEAR_STORIES_TAB_STATE; }>; type MarkStoriesTabViewedActionType = ReadonlyDeep<{ type: typeof MARK_STORIES_TAB_VIEWED; }>; type ViewStoryActionType = ReadonlyDeep<{ type: typeof VIEW_STORY; payload: SelectedStoryDataType | undefined; }>; type StoryReplyDeletedActionType = ReadonlyDeep<{ type: typeof STORY_REPLY_DELETED; payload: string; }>; type RemoveAllStoriesActionType = ReadonlyDeep<{ type: typeof REMOVE_ALL_STORIES; }>; type SetAddStoryDataType = ReadonlyDeep<{ type: typeof SET_ADD_STORY_DATA; payload: AddStoryData; }>; type SetStorySendingType = ReadonlyDeep<{ type: typeof SET_STORY_SENDING; payload: boolean; }>; type SetHasAllStoriesUnmutedType = ReadonlyDeep<{ type: typeof SET_HAS_ALL_STORIES_UNMUTED; payload: boolean; }>; // eslint-disable-next-line local-rules/type-alias-readonlydeep export type StoriesActionType = | DOEStoryActionType | ListMembersVerified | LoadStoryRepliesActionType | MarkStoryReadActionType | MessageChangedActionType | MessageDeletedActionType | MessagesAddedActionType | QueueStoryDownloadActionType | SendStoryModalOpenStateChanged | StoryChangedActionType | ClearStoriesTabStateActionType | MarkStoriesTabViewedActionType | ViewStoryActionType | StoryReplyDeletedActionType | RemoveAllStoriesActionType | TargetedConversationChangedActionType | SetAddStoryDataType | SetStorySendingType | SetHasAllStoriesUnmutedType; // Action Creators function deleteGroupStoryReply( messageId: string ): ThunkAction { return async dispatch => { await window.Signal.Data.removeMessage(messageId); dispatch({ type: STORY_REPLY_DELETED, payload: messageId, }); }; } function deleteGroupStoryReplyForEveryone( replyMessageId: string ): ThunkAction { return async dispatch => { await doDeleteGroupStoryReplyForEveryone(replyMessageId); // the call above re-uses the sync-message processing code to update the UI // we don't need to do anything here dispatch({ type: 'NOOP', payload: null, }); }; } function deleteStoryForEveryone( story: StoryViewType ): ThunkAction { return async (dispatch, getState) => { if (!story.sendState) { return; } const { stories } = getState().stories; const storyData = stories.find(item => item.messageId === story.messageId); if (!storyData) { log.warn('deleteStoryForEveryone: Could not find story in redux data'); return; } await doDeleteStoryForEveryone(stories, storyData); dispatch({ type: DOE_STORY, payload: story.messageId, }); }; } function loadStoryReplies( conversationId: string, messageId: string ): ThunkAction { return async (dispatch, getState) => { const conversation = getConversationSelector(getState())(conversationId); const replies = await dataInterface.getOlderMessagesByConversation({ conversationId, limit: 9000, storyId: messageId, includeStoryReplies: !isGroup(conversation), }); dispatch({ type: LOAD_STORY_REPLIES, payload: { messageId, replies, }, }); }; } function markStoryRead( messageId: string ): ThunkAction { return async (dispatch, getState) => { const { stories } = getState().stories; const matchingStory = stories.find(story => story.messageId === messageId); if (!matchingStory) { log.warn(`markStoryRead: no matching story found: ${messageId}`); return; } if ( !isDownloaded(matchingStory.attachment) && !hasFailed(matchingStory.attachment) ) { log.warn( `markStoryRead: not downloaded: ${messageId} ${ matchingStory.attachment?.error ? `error: ${matchingStory.attachment?.error}` : '' }` ); return; } const message = await __DEPRECATED$getMessageById(messageId); if (!message) { log.warn(`markStoryRead: no message found ${messageId}`); return; } const authorId = message.attributes.sourceServiceId; strictAssert( authorId, 'markStoryRead: The message needs a sender to mark it read!' ); strictAssert( isAciString(authorId), 'markStoryRead: The message needs a sender ACI to mark it read!' ); const isSignalOnboardingStory = authorId === SIGNAL_ACI; if (isSignalOnboardingStory) { const updatedMessages = await markOnboardingStoryAsRead(); if (updatedMessages) { return; } log.warn( 'markStoryRead: Failed to mark onboarding story read normally; failing over' ); } if (matchingStory.readStatus !== ReadStatus.Unread) { log.warn( `markStoryRead: not unread, ${messageId} read status: ${matchingStory.readStatus}` ); return; } const storyReadDate = Date.now(); message.set(markViewed(message.attributes, storyReadDate)); drop( dataInterface.saveMessage(message.attributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), }) ); const conversationId = message.get('conversationId'); const viewedReceipt = { messageId, conversationId, senderE164: message.attributes.source, senderAci: authorId, timestamp: message.attributes.sent_at, isDirectConversation: false, }; const viewSyncs: Array = [viewedReceipt]; if ( !isSignalOnboardingStory && !window.ConversationController.areWePrimaryDevice() ) { drop(viewSyncJobQueue.add({ viewSyncs })); } if ( !isSignalOnboardingStory && window.Events.getStoryViewReceiptsEnabled() ) { drop( conversationJobQueue.add({ type: conversationQueueJobEnum.enum.Receipts, conversationId, receiptsType: ReceiptType.Viewed, receipts: [viewedReceipt], }) ); } await dataInterface.addNewStoryRead({ authorId, conversationId: message.attributes.conversationId, storyId: messageId, storyReadDate, }); dispatch({ type: MARK_STORY_READ, payload: { messageId, readAt: storyReadDate, }, }); }; } function queueStoryDownload( storyId: string ): ThunkAction< void, RootStateType, unknown, NoopActionType | QueueStoryDownloadActionType > { return async (dispatch, getState) => { const { stories } = getState().stories; const story = stories.find(item => item.messageId === storyId); if (!story) { return; } const attachment = getStoryDownloadableAttachment(story); if (!attachment) { log.warn('queueStoryDownload: No attachment found for story', { storyId, }); return; } if (hasFailed(attachment)) { return; } if (isDownloaded(attachment)) { if (!attachment.path) { return; } return; } // isDownloading checks for the downloadJobId which is set by // queueAttachmentDownloads but we optimistically set story.startedDownload // in redux to prevent race conditions from queuing up multiple attachment // downloads before the attachment save takes place. if (isDownloading(attachment) || story.startedDownload) { return; } const message = await __DEPRECATED$getMessageById(storyId); if (message) { // We want to ensure that we re-hydrate the story reply context with the // completed attachment download. message.set({ storyReplyContext: undefined }); dispatch({ type: QUEUE_STORY_DOWNLOAD, payload: storyId, }); const updatedFields = await queueAttachmentDownloads(message.attributes); if (updatedFields) { message.set(updatedFields); } return; } dispatch({ type: 'NOOP', payload: null, }); }; } function reactToStory( nextReaction: string, messageId: string ): ThunkAction< void, RootStateType, unknown, ShowToastActionType | NoopActionType > { return async dispatch => { try { await enqueueReactionForSend({ messageId, emoji: nextReaction, remove: false, }); dispatch({ type: 'NOOP', payload: null, }); } catch (error) { log.error('Error enqueuing reaction', error, messageId, nextReaction); dispatch({ type: SHOW_TOAST, payload: { toastType: ToastType.ReactionFailed, }, }); } }; } function replyToStory( conversationId: string, messageBody: string, bodyRanges: DraftBodyRanges, timestamp: number, story: StoryViewType ): ThunkAction { return async (dispatch, getState) => { const conversation = window.ConversationController.get(conversationId); if (!conversation) { log.error('replyToStory: conversation does not exist', conversationId); return; } await conversation.enqueueMessageForSend( { body: messageBody, attachments: [], bodyRanges, }, { storyId: story.messageId, timestamp, } ); const { stories } = getState().stories; const storyData = stories.find(s => s.messageId === story.messageId); if (!storyData) { log.error('replyToStory: story not found', story.messageId); return; } // for group stories, this happens automatically through LOAD_STORY_REPLIES // but 1:1 we need to update manually dispatch( storyChanged({ ...storyData, hasReplies: true, hasRepliesFromSelf: true, }) ); }; } function sendStoryMessage( listIds: Array, conversationIds: Array, attachment: AttachmentType, bodyRanges: DraftBodyRanges | undefined ): ThunkAction< void, RootStateType, unknown, SendStoryModalOpenStateChanged | SetStorySendingType | SetAddStoryDataType > { return async (dispatch, getState) => { const { stories } = getState(); const { lastOpenedAtTimestamp, sendStoryModalData } = stories; // Add spinners in the story creator dispatch({ type: SET_STORY_SENDING, payload: true, }); assertDev( lastOpenedAtTimestamp, 'sendStoryMessage: lastOpenedAtTimestamp is undefined, cannot send' ); assertDev( sendStoryModalData, 'sendStoryMessage: sendStoryModalData is not defined, cannot send' ); log.info('sendStoryMessage: Verifying trust for all recipients'); const result = await blockSendUntilConversationsAreVerified( sendStoryModalData, SafetyNumberChangeSource.Story, Date.now() - lastOpenedAtTimestamp ); if (!result) { log.info('sendStoryMessage: failed to verify untrusted; stopping send'); dispatch({ type: SET_STORY_SENDING, payload: false, }); return; } try { await doSendStoryMessage( listIds, conversationIds, attachment, bodyRanges ); // Note: Only when we've successfully queued the message do we dismiss the story // composer view. dispatch({ type: SEND_STORY_MODAL_OPEN_STATE_CHANGED, payload: undefined, }); dispatch({ type: SET_ADD_STORY_DATA, payload: undefined, }); } catch (error) { log.error('sendStoryMessage:', Errors.toLogFormat(error)); // Get rid of spinners in the story creator dispatch({ type: SET_STORY_SENDING, payload: false, }); } }; } function storyChanged(story: StoryDataType): StoryChangedActionType { return { type: STORY_CHANGED, payload: story, }; } function sendStoryModalOpenStateChanged( value: boolean ): ThunkAction { return (dispatch, getState) => { const { stories } = getState(); if (!stories.sendStoryModalData && value) { dispatch({ type: SEND_STORY_MODAL_OPEN_STATE_CHANGED, payload: Date.now(), }); } if (stories.sendStoryModalData && !value) { dispatch({ type: SEND_STORY_MODAL_OPEN_STATE_CHANGED, payload: undefined, }); } }; } function clearStoriesTabState(): ClearStoriesTabStateActionType { return { type: CLEAR_STORIES_TAB_STATE, }; } function markStoriesTabViewed(): MarkStoriesTabViewedActionType { return { type: MARK_STORIES_TAB_VIEWED, }; } function verifyStoryListMembers({ conversationId, distributionId, serviceIds, }: { conversationId: string; distributionId: StoryDistributionIdString | undefined; serviceIds: Array; }): ThunkAction { return async (dispatch, getState) => { const { stories } = getState(); const { sendStoryModalData } = stories; if (!sendStoryModalData) { return; } if (!serviceIds.length) { return; } // This will fetch the latest identity key for these contacts, which will ensure that // the later verified/trusted checks will flag that change. await doVerifyStoryListMembers(serviceIds); dispatch({ type: LIST_MEMBERS_VERIFIED, payload: { conversationId, distributionId, serviceIds, }, }); }; } const getSelectedStoryDataForDistributionListId = ( getState: () => RootStateType, distributionListId: string | undefined, selectedStoryId?: string ): { currentIndex: number; numStories: number; storiesByConversationId: Array; } => { const state = getState(); const { stories } = state.stories; const storiesByDistributionList = stories.filter( item => item.storyDistributionListId === distributionListId && !item.deletedForEveryone ); const numStories = storiesByDistributionList.length; const currentIndex = selectedStoryId ? storiesByDistributionList.findIndex( item => item.messageId === selectedStoryId ) : 0; return { currentIndex, numStories, storiesByConversationId: [], }; }; const getSelectedStoryDataForConversationId = ( dispatch: ThunkDispatch, getState: () => RootStateType, { conversationId, onlyFromSelf, selectedStoryId, }: { conversationId: string; onlyFromSelf: boolean; selectedStoryId?: string; } ): { currentIndex: number; hasUnread: boolean; numStories: number; storiesByConversationId: Array; } => { const state = getState(); const { stories } = state.stories; const ourAci = window.storage.user.getCheckedAci(); const storiesByConversationId = stories.filter( item => item.conversationId === conversationId && !item.deletedForEveryone && (!onlyFromSelf || item.sourceServiceId === ourAci) ); // Find the index of the storyId provided, or if none provided then find the // oldest unviewed story from the user. If all stories are read then we can // start at the first story. let currentIndex: number | undefined; let hasUnread = false; storiesByConversationId.forEach((item, index) => { if (selectedStoryId && item.messageId === selectedStoryId) { currentIndex = index; } if ( !selectedStoryId && currentIndex === undefined && item.readStatus === ReadStatus.Unread ) { hasUnread = true; currentIndex = index; } }); const numStories = storiesByConversationId.length; // Queue all undownloaded stories once we're viewing someone's stories storiesByConversationId.forEach(({ attachment, messageId }) => { if (isDownloaded(attachment) || isDownloading(attachment)) { return; } queueStoryDownload(messageId)(dispatch, getState, null); }); return { currentIndex: currentIndex ?? 0, hasUnread, numStories, storiesByConversationId, }; }; export type ViewUserStoriesActionCreatorType = ReadonlyDeep< (opts: { conversationId: string; storyViewMode?: StoryViewModeType; viewTarget?: StoryViewTargetType; }) => unknown >; const viewUserStories: ViewUserStoriesActionCreatorType = ({ conversationId, storyViewMode, viewTarget, }): ThunkAction => { return (dispatch, getState) => { const { currentIndex, hasUnread, numStories, storiesByConversationId } = getSelectedStoryDataForConversationId(dispatch, getState, { conversationId, onlyFromSelf: false, }); const story = storiesByConversationId[currentIndex]; const state = getState(); const hiddenConversationIds = new Set(getHideStoryConversationIds(state)); let inferredStoryViewMode: StoryViewModeType; if (storyViewMode) { inferredStoryViewMode = storyViewMode; } else if (hiddenConversationIds.has(conversationId)) { inferredStoryViewMode = StoryViewModeType.Hidden; } else if (hasUnread) { inferredStoryViewMode = StoryViewModeType.Unread; } else { inferredStoryViewMode = StoryViewModeType.All; } let unviewedStoryConversationIdsSorted: Array = []; if ( inferredStoryViewMode === StoryViewModeType.Unread || inferredStoryViewMode === StoryViewModeType.Hidden ) { const storiesSelectorState = getStories(state); const conversationStories = inferredStoryViewMode === StoryViewModeType.Hidden ? storiesSelectorState.hiddenStories : storiesSelectorState.stories; unviewedStoryConversationIdsSorted = conversationStories .filter(item => item.storyView.isUnread) .map(item => item.conversationId); } dispatch({ type: VIEW_STORY, payload: { currentIndex, messageId: story.messageId, numStories, storyViewMode: inferredStoryViewMode, unviewedStoryConversationIdsSorted, viewTarget, }, }); }; }; function removeAllStories(): RemoveAllStoriesActionType { return { type: REMOVE_ALL_STORIES, }; } type ViewStoryOptionsType = ReadonlyDeep< | { closeViewer: true; } | { storyId: string; storyViewMode: StoryViewModeType; viewDirection?: StoryViewDirectionType; viewTarget?: StoryViewTargetType; } >; export type ViewStoryActionCreatorType = ReadonlyDeep< (opts: ViewStoryOptionsType) => unknown >; export type DispatchableViewStoryType = ReadonlyDeep< ( opts: ViewStoryOptionsType ) => ThunkAction >; const viewStory: ViewStoryActionCreatorType = ( opts ): ThunkAction => { return (dispatch, getState) => { if ('closeViewer' in opts) { dispatch({ type: VIEW_STORY, payload: undefined, }); return; } const { viewTarget, storyId, storyViewMode, viewDirection } = opts; const state = getState(); const { selectedStoryData, stories } = state.stories; const unviewedStoryConversationIdsSorted = selectedStoryData?.unviewedStoryConversationIdsSorted || []; // Spec: // When opening the story viewer you should always be taken to the oldest // un viewed story of the user you tapped on // If all stories from a user are viewed, opening the viewer should take // you to their oldest story const story = stories.find( item => item.messageId === storyId && !item.deletedForEveryone ); if (!story) { log.warn('stories.viewStory: No story found', storyId); dispatch({ type: VIEW_STORY, payload: undefined, }); return; } const { currentIndex, numStories, storiesByConversationId } = storyViewMode === StoryViewModeType.MyStories && story.storyDistributionListId ? getSelectedStoryDataForDistributionListId( getState, story.storyDistributionListId, storyId ) : getSelectedStoryDataForConversationId(dispatch, getState, { conversationId: story.conversationId, onlyFromSelf: storyViewMode === StoryViewModeType.MyStories, selectedStoryId: storyId, }); // Go directly to the storyId selected if (!viewDirection) { const hasFailedSend = Object.values( story.sendStateByConversationId || {} ).some(({ status }) => status === SendStatus.Failed); dispatch({ type: VIEW_STORY, payload: { currentIndex, messageId: storyId, numStories, storyViewMode, unviewedStoryConversationIdsSorted, viewTarget: hasFailedSend ? undefined : viewTarget, }, }); return; } if (storyViewMode === StoryViewModeType.Single) { // Close the viewer we were just looking at a single story. dispatch({ type: VIEW_STORY, payload: undefined, }); return; } // When paging through all sent stories // Note the order is reversed[1][2] here because we sort the stories by // recency in descending order but the story viewer plays them in // ascending order. if (storyViewMode === StoryViewModeType.MyStories) { const { myStories } = getStories(state); let currentStoryIndex = -1; const currentDistributionListIndex = myStories.findIndex(item => { for (let i = item.stories.length - 1; i >= 0; i -= 1) { const myStory = item.stories[i]; if (myStory.messageId === storyId) { // [1] reversed currentStoryIndex = item.stories.length - 1 - i; return true; } } return false; }); if (currentDistributionListIndex < 0 || currentStoryIndex < 0) { log.warn('stories.viewStory: No current story found for MyStories', { currentDistributionListIndex, currentStoryIndex, myStories: myStories.length, }); dispatch({ type: VIEW_STORY, payload: undefined, }); return; } let nextSentStoryId: string | undefined; let nextSentStoryIndex = -1; let nextNumStories = numStories; // [2] reversed const currentStories = myStories[currentDistributionListIndex].stories .slice() .reverse(); if (viewDirection === StoryViewDirectionType.Next) { if (currentStoryIndex < currentStories.length - 1) { nextSentStoryIndex = currentStoryIndex + 1; nextSentStoryId = currentStories[nextSentStoryIndex].messageId; } else if (currentDistributionListIndex < myStories.length - 1) { const nextSentStoryContainer = myStories[currentDistributionListIndex + 1]; nextNumStories = nextSentStoryContainer.stories.length; nextSentStoryIndex = 0; nextSentStoryId = nextSentStoryContainer.stories[nextNumStories - 1].messageId; } } if (viewDirection === StoryViewDirectionType.Previous) { if (currentStoryIndex > 0) { nextSentStoryIndex = currentStoryIndex - 1; nextSentStoryId = currentStories[nextSentStoryIndex].messageId; } else if (currentDistributionListIndex > 0) { const nextSentStoryContainer = myStories[currentDistributionListIndex - 1]; nextNumStories = nextSentStoryContainer.stories.length; nextSentStoryIndex = nextNumStories - 1; nextSentStoryId = nextSentStoryContainer.stories[0].messageId; } } if (!nextSentStoryId) { dispatch({ type: VIEW_STORY, payload: undefined, }); return; } dispatch({ type: VIEW_STORY, payload: { currentIndex: nextSentStoryIndex, messageId: nextSentStoryId, numStories: nextNumStories, storyViewMode, unviewedStoryConversationIdsSorted, }, }); return; } // Next story within the same user's stories if ( viewDirection === StoryViewDirectionType.Next && currentIndex < numStories - 1 ) { const nextIndex = currentIndex + 1; const nextStory = storiesByConversationId[nextIndex]; dispatch({ type: VIEW_STORY, payload: { currentIndex: nextIndex, messageId: nextStory.messageId, numStories, storyViewMode, unviewedStoryConversationIdsSorted, }, }); return; } // Prev story within the same user's stories if (viewDirection === StoryViewDirectionType.Previous && currentIndex > 0) { const nextIndex = currentIndex - 1; const nextStory = storiesByConversationId[nextIndex]; dispatch({ type: VIEW_STORY, payload: { currentIndex: nextIndex, messageId: nextStory.messageId, numStories, storyViewMode, unviewedStoryConversationIdsSorted, }, }); return; } // We were just viewing a single user's stories. Close the viewer. if (storyViewMode === StoryViewModeType.User) { dispatch({ type: VIEW_STORY, payload: undefined, }); return; } const storiesSelectorState = getStories(state); const conversationStories = storyViewMode === StoryViewModeType.Hidden ? storiesSelectorState.hiddenStories : storiesSelectorState.stories; const conversationStoryIndex = conversationStories.findIndex( item => item.conversationId === story.conversationId ); // Are there any unviewed stories left? If so we should play the unviewed // stories first. if (storyViewMode === StoryViewModeType.Unread) { const frozenConversationStoryIndex = unviewedStoryConversationIdsSorted.findIndex( conversationId => conversationId === story.conversationId ); let nextUnreadConversationId: string | undefined; if (viewDirection === StoryViewDirectionType.Previous) { nextUnreadConversationId = unviewedStoryConversationIdsSorted[frozenConversationStoryIndex - 1]; } else if (viewDirection === StoryViewDirectionType.Next) { nextUnreadConversationId = unviewedStoryConversationIdsSorted[frozenConversationStoryIndex + 1]; } if (nextUnreadConversationId) { const nextSelectedStoryData = getSelectedStoryDataForConversationId( dispatch, getState, { conversationId: nextUnreadConversationId, onlyFromSelf: false, } ); dispatch({ type: VIEW_STORY, payload: { currentIndex: nextSelectedStoryData.currentIndex, messageId: nextSelectedStoryData.storiesByConversationId[ nextSelectedStoryData.currentIndex ].messageId, numStories: nextSelectedStoryData.numStories, storyViewMode, unviewedStoryConversationIdsSorted, }, }); return; } // Close the viewer if we were viewing unviewed stories only and we did // not find any more unviewed. dispatch({ type: VIEW_STORY, payload: undefined, }); return; } if (conversationStoryIndex < 0) { log.warn('stories.viewStory: No stories found for conversation', { storiesLength: conversationStories.length, }); dispatch({ type: VIEW_STORY, payload: undefined, }); return; } // Find the next user's stories if ( viewDirection === StoryViewDirectionType.Next && conversationStoryIndex < conversationStories.length - 1 ) { // Spec: // Tapping right advances you to the next un viewed story // If all stories are viewed, advance to the next viewed story // When you reach the newest story from a user, tapping right again // should take you to the next user's oldest un viewed story or oldest // story if all stories for the next user are viewed. // When you reach the newest story from the last user in the story list, // tapping right should close the viewer // Touch area for tapping right should be 80% of width of the screen const nextConversationStoryIndex = conversationStoryIndex + 1; const conversationStory = conversationStories[nextConversationStoryIndex]; const nextSelectedStoryData = getSelectedStoryDataForConversationId( dispatch, getState, { conversationId: conversationStory.conversationId, onlyFromSelf: false, } ); dispatch({ type: VIEW_STORY, payload: { currentIndex: 0, messageId: nextSelectedStoryData.storiesByConversationId[0].messageId, numStories: nextSelectedStoryData.numStories, storyViewMode, unviewedStoryConversationIdsSorted, }, }); return; } // Find the previous user's stories if ( viewDirection === StoryViewDirectionType.Previous && conversationStoryIndex > 0 ) { // Spec: // Tapping left takes you back to the previous story // When you reach the oldest story from a user, tapping left again takes // you to the previous users oldest un viewed story or newest viewed // story if all stories are viewed // If you tap left on the oldest story from the first user in the story // list, it should re-start playback on that story // Touch area for tapping left should be 20% of width of the screen const nextConversationStoryIndex = conversationStoryIndex - 1; const conversationStory = conversationStories[nextConversationStoryIndex]; const nextSelectedStoryData = getSelectedStoryDataForConversationId( dispatch, getState, { conversationId: conversationStory.conversationId, onlyFromSelf: false, } ); dispatch({ type: VIEW_STORY, payload: { currentIndex: 0, messageId: nextSelectedStoryData.storiesByConversationId[0].messageId, numStories: nextSelectedStoryData.numStories, storyViewMode, unviewedStoryConversationIdsSorted, }, }); return; } // Could not meet any criteria, close the viewer dispatch({ type: VIEW_STORY, payload: undefined, }); }; }; function setAddStoryData(addStoryData: AddStoryData): SetAddStoryDataType { return { type: SET_ADD_STORY_DATA, payload: addStoryData, }; } function setStorySending(sending: boolean): SetStorySendingType { return { type: SET_STORY_SENDING, payload: sending, }; } function setHasAllStoriesUnmuted( isUnmuted: boolean ): SetHasAllStoriesUnmutedType { return { type: SET_HAS_ALL_STORIES_UNMUTED, payload: isUnmuted, }; } function setStoriesDisabled( value: boolean ): ThunkAction { return async () => { await window.Events.setHasStoriesDisabled(value); }; } function removeAllContactStories( conversationId: string ): ThunkAction { return async (dispatch, getState) => { const logId = `removeAllContactStories(${conversationId})`; const { stories } = getState().stories; const messageIds = stories .filter(item => item.conversationId === conversationId) .map(({ messageId }) => messageId); const messages = ( await Promise.all( messageIds.map(async messageId => { const message = await __DEPRECATED$getMessageById(messageId); if (!message) { log.warn(`${logId}: no message found ${messageId}`); return; } return message; }) ) ).filter(isNotNil); log.info(`${logId}: removing ${messages.length} stories`); await Promise.all([ messages.map(m => m.cleanup()), await dataInterface.removeMessages(messageIds), ]); dispatch({ type: 'NOOP', payload: null, }); }; } export const actions = { deleteStoryForEveryone, loadStoryReplies, markStoryRead, queueStoryDownload, reactToStory, removeAllStories, replyToStory, sendStoryMessage, sendStoryModalOpenStateChanged, storyChanged, clearStoriesTabState, markStoriesTabViewed, verifyStoryListMembers, viewUserStories, viewStory, deleteGroupStoryReply, deleteGroupStoryReplyForEveryone, setAddStoryData, setStoriesDisabled, setHasAllStoriesUnmuted, setStorySending, removeAllContactStories, }; export const useStoriesActions = (): BoundActionCreatorsMapObject< typeof actions > => useBoundActions(actions); // Reducer export function getEmptyState( overrideState: Partial = {} ): StoriesStateType { return { lastOpenedAtTimestamp: undefined, addStoryData: undefined, stories: [], hasAllStoriesUnmuted: false, ...overrideState, }; } export function reducer( state: Readonly = getEmptyState(), action: Readonly ): StoriesStateType { if (action.type === MARK_STORIES_TAB_VIEWED) { return { ...state, lastOpenedAtTimestamp: Date.now(), replyState: undefined, sendStoryModalData: undefined, }; } if (action.type === CLEAR_STORIES_TAB_STATE) { return { ...state, replyState: undefined, sendStoryModalData: undefined, selectedStoryData: undefined, addStoryData: undefined, }; } if (action.type === STORY_REPLY_DELETED) { return { ...state, replyState: state.replyState ? { ...state.replyState, replies: state.replyState.replies.filter( reply => reply.id !== action.payload ), } : undefined, }; } 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: nextStories, }; } if (action.type === STORY_CHANGED) { const newStory = pick(action.payload, [ 'attachment', 'bodyRanges', 'canReplyToStory', 'conversationId', 'deletedForEveryone', 'expirationStartTimestamp', 'expireTimer', 'hasReplies', 'hasRepliesFromSelf', 'messageId', 'reactions', 'readAt', 'readStatus', 'sendStateByConversationId', 'source', 'sourceServiceId', 'sourceDevice', 'storyDistributionListId', 'timestamp', 'type', ]); const prevStoryIndex = state.stories.findIndex( existingStory => existingStory.messageId === newStory.messageId ); if (prevStoryIndex >= 0) { const prevStory = state.stories[prevStoryIndex]; // Stories rarely need to change, here are the following exceptions... // These only change because of initialization order - these fields are updated // after the model is created: const bodyRangesChanged = newStory.bodyRanges?.length !== prevStory.bodyRanges?.length; const hasExpirationChanged = (newStory.expirationStartTimestamp && !prevStory.expirationStartTimestamp) || (newStory.expireTimer && !prevStory.expireTimer); // These reflect changes in status over time: const isDownloadingAttachment = isDownloading(newStory.attachment); const hasAttachmentDownloaded = !isDownloaded(prevStory.attachment) && isDownloaded(newStory.attachment); const hasAttachmentFailed = hasFailed(newStory.attachment) && !hasFailed(prevStory.attachment); const readStatusChanged = prevStory.readStatus !== newStory.readStatus; const reactionsChanged = prevStory.reactions?.length !== newStory.reactions?.length; const hasBeenDeleted = !prevStory.deletedForEveryone && newStory.deletedForEveryone; const hasSendStateChanged = !isEqual( prevStory.sendStateByConversationId, newStory.sendStateByConversationId ); const hasHasRepliesChanged = prevStory.hasReplies !== newStory.hasReplies || prevStory.hasRepliesFromSelf !== newStory.hasRepliesFromSelf; const shouldReplace = bodyRangesChanged || isDownloadingAttachment || hasAttachmentDownloaded || hasAttachmentFailed || hasBeenDeleted || hasExpirationChanged || hasSendStateChanged || readStatusChanged || reactionsChanged || hasHasRepliesChanged; if (!shouldReplace) { return state; } if (hasBeenDeleted) { return { ...state, stories: state.stories.filter( existingStory => existingStory.messageId !== newStory.messageId ), }; } return { ...state, stories: replaceIndex(state.stories, prevStoryIndex, newStory), }; } // Adding a new story const stories = [...state.stories, newStory].sort((a, b) => a.timestamp > b.timestamp ? 1 : -1 ); return { ...state, stories, }; } if (action.type === MARK_STORY_READ) { const { messageId, readAt } = action.payload; return { ...state, stories: state.stories.map(story => { if (story.messageId === messageId) { return { ...story, readAt, readStatus: ReadStatus.Viewed, }; } return story; }), }; } if (action.type === LOAD_STORY_REPLIES) { return { ...state, stories: state.stories.map(story => { if (story.messageId === action.payload.messageId) { return { ...story, hasReplies: action.payload.replies.length > 0, hasRepliesFromSelf: action.payload.replies.some( reply => reply.type === 'outgoing' ), }; } return story; }), replyState: action.payload, }; } if (action.type === 'MESSAGES_ADDED' && action.payload.isJustSent) { const stories = action.payload.messages.filter(isStory); if (!stories.length) { return state; } const newStories = stories .map(messageAttrs => getStoryDataFromMessageAttributes(messageAttrs)) .filter(isNotNil); if (!newStories.length) { return state; } return { ...state, stories: [...state.stories, ...newStories], }; } // 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) { const storyIndex = state.stories.findIndex( story => story.messageId === replyState.messageId ); const stories = storyIndex >= 0 ? replaceIndex(state.stories, storyIndex, { ...state.stories[storyIndex], hasReplies: true, hasRepliesFromSelf: state.stories[storyIndex].hasRepliesFromSelf || state.stories[storyIndex].conversationId === action.payload.conversationId, }) : state.stories; return { ...state, stories, 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 === DOE_STORY) { return { ...state, selectedStoryData: undefined, stories: state.stories.filter( existingStory => existingStory.messageId !== action.payload ), }; } if (action.type === VIEW_STORY) { return { ...state, selectedStoryData: action.payload, }; } if (action.type === REMOVE_ALL_STORIES) { return getEmptyState(); } if (action.type === QUEUE_STORY_DOWNLOAD) { const storyIndex = state.stories.findIndex( story => story.messageId === action.payload ); if (storyIndex < 0) { return state; } const existingStory = state.stories[storyIndex]; return { ...state, stories: replaceIndex(state.stories, storyIndex, { ...existingStory, startedDownload: true, }), }; } if (action.type === SEND_STORY_MODAL_OPEN_STATE_CHANGED) { if (action.payload) { return { ...state, sendStoryModalData: {}, }; } return { ...state, sendStoryModalData: undefined, }; } if (action.type === LIST_MEMBERS_VERIFIED) { const { sendStoryModalData } = state; const { conversationId, distributionId, serviceIds } = action.payload; const existing = sendStoryModalData && getOwn(sendStoryModalData, conversationId); if (distributionId) { const existingServiceIds = existing?.byDistributionId?.[distributionId]?.serviceIds; const finalServiceIds = Array.from( new Set([...(existingServiceIds || []), ...serviceIds]) ); return { ...state, sendStoryModalData: { ...sendStoryModalData, [conversationId]: { ...existing, serviceIds: existing?.serviceIds || [], byDistributionId: { ...existing?.byDistributionId, [distributionId]: { serviceIds: finalServiceIds, }, }, }, }, }; } const finalServiceIds = Array.from( new Set([...(existing?.serviceIds || []), ...serviceIds]) ); return { ...state, sendStoryModalData: { ...sendStoryModalData, [conversationId]: { ...existing, serviceIds: finalServiceIds, }, }, }; } if (action.type === SET_ADD_STORY_DATA) { return { ...state, addStoryData: action.payload, }; } if (action.type === SET_STORY_SENDING) { const existing = state.addStoryData; if (!existing) { log.warn( 'stories/reducer: Set story sending, but no existing addStoryData' ); return state; } return { ...state, addStoryData: { ...existing, sending: action.payload, }, }; } if (action.type === SET_HAS_ALL_STORIES_UNMUTED) { return { ...state, hasAllStoriesUnmuted: action.payload, }; } if (action.type === TARGETED_CONVERSATION_CHANGED) { return { ...state, replyState: undefined, sendStoryModalData: undefined, selectedStoryData: undefined, }; } return state; }