diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index fdee34273c..86e3397d85 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -40,7 +40,10 @@ import { isDownloaded, isDownloading, } from '../../types/Attachment'; -import { getConversationSelector } from '../selectors/conversations'; +import { + getConversationSelector, + getHideStoryConversationIds, +} from '../selectors/conversations'; import { getSendOptions } from '../../util/getSendOptions'; import { getStories } from '../selectors/stories'; import { getStoryDataFromMessageAttributes } from '../../services/storyLoader'; @@ -826,19 +829,25 @@ const viewUserStories: ViewUserStoriesActionCreatorType = ({ }; }; +type ViewStoryOptionsType = + | { + closeViewer: true; + } + | { + storyId: string; + storyViewMode: StoryViewModeType; + viewDirection?: StoryViewDirectionType; + shouldShowDetailsModal?: boolean; + }; + export type ViewStoryActionCreatorType = ( - opts: - | { - closeViewer: true; - } - | { - storyId: string; - storyViewMode: StoryViewModeType; - viewDirection?: StoryViewDirectionType; - shouldShowDetailsModal?: boolean; - } + opts: ViewStoryOptionsType ) => unknown; +export type DispatchableViewStoryType = ( + opts: ViewStoryOptionsType +) => ThunkAction; + const viewStory: ViewStoryActionCreatorType = ( opts ): ThunkAction => { @@ -954,10 +963,20 @@ const viewStory: ViewStoryActionCreatorType = ( // Are there any unviewed stories left? If so we should play the unviewed // stories first. But only if we're going "next" if (viewDirection === StoryViewDirectionType.Next) { - const unreadStory = stories.find( - item => - item.readStatus === ReadStatus.Unread && !item.deletedForEveryone + // Only stories that succeed the current story we're on. + const currentStoryIndex = stories.findIndex( + item => item.messageId === storyId ); + // No hidden stories + const hiddenConversationIds = new Set(getHideStoryConversationIds(state)); + const unreadStory = stories.find( + (item, index) => + index > currentStoryIndex && + !item.deletedForEveryone && + item.readStatus === ReadStatus.Unread && + !hiddenConversationIds.has(item.conversationId) + ); + if (unreadStory) { const nextSelectedStoryData = getSelectedStoryDataForConversationId( dispatch, @@ -977,6 +996,16 @@ const viewStory: ViewStoryActionCreatorType = ( }); return; } + + // Close the viewer if we were viewing unread stories only and we did not + // find any more unread. + if (storyViewMode === StoryViewModeType.Unread) { + dispatch({ + type: VIEW_STORY, + payload: undefined, + }); + return; + } } const conversationStories = getStories(state).stories; @@ -1018,19 +1047,6 @@ const viewStory: ViewStoryActionCreatorType = ( conversationStory.conversationId ); - // Close the viewer if we were viewing unread stories only and we've - // reached the last unread story. - if ( - !nextSelectedStoryData.hasUnread && - storyViewMode === StoryViewModeType.Unread - ) { - dispatch({ - type: VIEW_STORY, - payload: undefined, - }); - return; - } - dispatch({ type: VIEW_STORY, payload: { diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 394f787fd6..59dcfb4b3a 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -1050,3 +1050,11 @@ export const getConversationsStoppingSend = createSelector( return sortByTitle(conversations); } ); + +export const getHideStoryConversationIds = createSelector( + getConversationLookup, + (conversationLookup): Array => + Object.keys(conversationLookup).filter( + conversationId => conversationLookup[conversationId].hideStory + ) +); diff --git a/ts/state/selectors/stories.ts b/ts/state/selectors/stories.ts index 016043a0f9..c1764ae109 100644 --- a/ts/state/selectors/stories.ts +++ b/ts/state/selectors/stories.ts @@ -27,6 +27,7 @@ import { canReply } from './message'; import { getContactNameColorSelector, getConversationSelector, + getHideStoryConversationIds, getMe, } from './conversations'; import { getUserConversationId } from './user'; @@ -412,15 +413,19 @@ export const getStories = createSelector( ); export const getStoriesNotificationCount = createSelector( + getHideStoryConversationIds, getStoriesState, - ({ lastOpenedAtTimestamp, stories }): number => { + (hideStoryConversationIds, { lastOpenedAtTimestamp, stories }): number => { + const hiddenConversationIds = new Set(hideStoryConversationIds); + return new Set( stories .filter( story => story.readStatus === ReadStatus.Unread && !story.deletedForEveryone && - story.timestamp > (lastOpenedAtTimestamp || 0) + story.timestamp > (lastOpenedAtTimestamp || 0) && + !hiddenConversationIds.has(story.conversationId) ) .map(story => story.conversationId) ).size; diff --git a/ts/test-electron/state/ducks/stories_test.ts b/ts/test-electron/state/ducks/stories_test.ts index 9d4832020d..3d7c908be1 100644 --- a/ts/test-electron/state/ducks/stories_test.ts +++ b/ts/test-electron/state/ducks/stories_test.ts @@ -2,13 +2,26 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as sinon from 'sinon'; +import casual from 'casual'; import path from 'path'; import { assert } from 'chai'; import { v4 as uuid } from 'uuid'; -import type { StoriesStateType } from '../../../state/ducks/stories'; +import type { + DispatchableViewStoryType, + StoriesStateType, + StoryDataType, +} from '../../../state/ducks/stories'; +import type { ConversationType } from '../../../state/ducks/conversations'; import type { MessageAttributesType } from '../../../model-types.d'; +import type { StateType as RootStateType } from '../../../state/reducer'; +import { DAY } from '../../../util/durations'; import { IMAGE_JPEG } from '../../../types/MIME'; +import { ReadStatus } from '../../../messages/MessageReadStatus'; +import { + StoryViewDirectionType, + StoryViewModeType, +} from '../../../types/Stories'; import { actions, getEmptyState, @@ -38,6 +51,405 @@ describe('both/state/ducks/stories', () => { }; } + describe('viewStory', () => { + function getMockConversation({ + id: conversationId, + hideStory = false, + }: Pick): ConversationType { + return { + acceptedMessageRequest: true, + badges: [], + hideStory, + id: conversationId, + isMe: false, + sharedGroupNames: [], + title: casual.username, + type: 'direct' as const, + }; + } + + function getStoryData( + messageId: string, + conversationId = uuid() + ): StoryDataType { + const now = Date.now(); + + return { + conversationId, + expirationStartTimestamp: now, + expireTimer: 1 * DAY, + messageId, + readStatus: ReadStatus.Unread, + timestamp: now, + type: 'story', + }; + } + + function getStateFunction( + stories: Array, + conversationLookup: { [key: string]: ConversationType } = {} + ): () => RootStateType { + const rootState = getEmptyRootState(); + + return () => ({ + ...rootState, + conversations: { + ...rootState.conversations, + conversationLookup, + }, + stories: { + ...rootState.stories, + stories, + }, + }); + } + + const viewStory = actions.viewStory as DispatchableViewStoryType; + + it('closes the viewer', () => { + const dispatch = sinon.spy(); + + viewStory({ closeViewer: true })(dispatch, getEmptyRootState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: undefined, + }); + }); + + it('does not find a story', () => { + const dispatch = sinon.spy(); + viewStory({ + storyId: uuid(), + storyViewMode: StoryViewModeType.All, + })(dispatch, getEmptyRootState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: undefined, + }); + }); + + it('selects a specific story', () => { + const storyId = uuid(); + + const getState = getStateFunction([getStoryData(storyId)]); + + const dispatch = sinon.spy(); + viewStory({ + storyId, + storyViewMode: StoryViewModeType.All, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: { + currentIndex: 0, + messageId: storyId, + numStories: 1, + shouldShowDetailsModal: false, + storyViewMode: StoryViewModeType.All, + }, + }); + }); + + describe("navigating within a user's stories", () => { + it('selects the next story', () => { + const storyId1 = uuid(); + const storyId2 = uuid(); + const storyId3 = uuid(); + const conversationId = uuid(); + const getState = getStateFunction([ + getStoryData(storyId1, conversationId), + getStoryData(storyId2, conversationId), + getStoryData(storyId3, conversationId), + ]); + + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId1, + storyViewMode: StoryViewModeType.User, + viewDirection: StoryViewDirectionType.Next, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: { + currentIndex: 1, + messageId: storyId2, + numStories: 3, + shouldShowDetailsModal: false, + storyViewMode: StoryViewModeType.User, + }, + }); + }); + + it('selects the prev story', () => { + const storyId1 = uuid(); + const storyId2 = uuid(); + const storyId3 = uuid(); + const conversationId = uuid(); + const getState = getStateFunction([ + getStoryData(storyId1, conversationId), + getStoryData(storyId2, conversationId), + getStoryData(storyId3, conversationId), + ]); + + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId2, + storyViewMode: StoryViewModeType.User, + viewDirection: StoryViewDirectionType.Previous, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: { + currentIndex: 0, + messageId: storyId1, + numStories: 3, + shouldShowDetailsModal: false, + storyViewMode: StoryViewModeType.User, + }, + }); + }); + + it('when in StoryViewModeType.User and we have reached the end, it closes the viewer', () => { + const storyId1 = uuid(); + const storyId2 = uuid(); + const storyId3 = uuid(); + const conversationId = uuid(); + const getState = getStateFunction([ + getStoryData(storyId1, conversationId), + getStoryData(storyId2, conversationId), + getStoryData(storyId3, conversationId), + ]); + + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId3, + storyViewMode: StoryViewModeType.User, + viewDirection: StoryViewDirectionType.Next, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: undefined, + }); + }); + }); + + describe('unviewed stories', () => { + it('finds any unviewed stories and selects them', () => { + const storyId1 = uuid(); + const storyId2 = uuid(); + const storyId3 = uuid(); + const getState = getStateFunction([ + getStoryData(storyId1), + { + ...getStoryData(storyId2), + readStatus: ReadStatus.Viewed, + }, + getStoryData(storyId3), + ]); + + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId1, + storyViewMode: StoryViewModeType.Unread, + viewDirection: StoryViewDirectionType.Next, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: { + currentIndex: 0, + messageId: storyId3, + numStories: 1, + shouldShowDetailsModal: false, + storyViewMode: StoryViewModeType.Unread, + }, + }); + }); + + it('does not select hidden stories', () => { + const storyId1 = uuid(); + const storyId2 = uuid(); + const storyId3 = uuid(); + const conversationId = uuid(); + + const getState = getStateFunction( + [ + getStoryData(storyId1), + getStoryData(storyId2, conversationId), + getStoryData(storyId3, conversationId), + ], + { + [conversationId]: getMockConversation({ + id: conversationId, + hideStory: true, + }), + } + ); + + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId1, + storyViewMode: StoryViewModeType.Unread, + viewDirection: StoryViewDirectionType.Next, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: undefined, + }); + }); + + it('does not select stories that precede the currently viewed story', () => { + const storyId1 = uuid(); + const storyId2 = uuid(); + const storyId3 = uuid(); + const getState = getStateFunction([ + getStoryData(storyId1), + getStoryData(storyId2), + getStoryData(storyId3), + ]); + + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId3, + storyViewMode: StoryViewModeType.Unread, + viewDirection: StoryViewDirectionType.Next, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: undefined, + }); + }); + + it('closes the viewer when there are no more unviewed stories', () => { + const storyId1 = uuid(); + const storyId2 = uuid(); + + const conversationId1 = uuid(); + const conversationId2 = uuid(); + + const getState = getStateFunction( + [ + getStoryData(storyId1, conversationId1), + { + ...getStoryData(storyId2, conversationId2), + readStatus: ReadStatus.Viewed, + }, + ], + { + [conversationId1]: getMockConversation({ id: conversationId1 }), + [conversationId2]: getMockConversation({ id: conversationId2 }), + } + ); + + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId1, + storyViewMode: StoryViewModeType.Unread, + viewDirection: StoryViewDirectionType.Next, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: undefined, + }); + }); + }); + + describe('paging through collections of stories', () => { + function getViewedStoryData( + storyId: string, + conversationId?: string + ): StoryDataType { + return { + ...getStoryData(storyId, conversationId), + readStatus: ReadStatus.Viewed, + }; + } + + it("goes to the next user's stories", () => { + const storyId1 = uuid(); + const storyId2 = uuid(); + const storyId3 = uuid(); + const conversationId2 = uuid(); + const conversationId1 = uuid(); + const getState = getStateFunction( + [ + getViewedStoryData(storyId1, conversationId1), + getViewedStoryData(storyId2, conversationId2), + getViewedStoryData(storyId3, conversationId2), + ], + { + [conversationId1]: getMockConversation({ id: conversationId1 }), + [conversationId2]: getMockConversation({ id: conversationId2 }), + } + ); + + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId1, + storyViewMode: StoryViewModeType.All, + viewDirection: StoryViewDirectionType.Next, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: { + currentIndex: 0, + messageId: storyId2, + numStories: 2, + shouldShowDetailsModal: false, + storyViewMode: StoryViewModeType.All, + }, + }); + }); + + it("goes to the prev user's stories", () => { + const storyId1 = uuid(); + const storyId2 = uuid(); + const storyId3 = uuid(); + const conversationId1 = uuid(); + const conversationId2 = uuid(); + const getState = getStateFunction( + [ + getViewedStoryData(storyId1, conversationId2), + getViewedStoryData(storyId2, conversationId1), + getViewedStoryData(storyId3, conversationId2), + ], + { + [conversationId1]: getMockConversation({ id: conversationId1 }), + [conversationId2]: getMockConversation({ id: conversationId2 }), + } + ); + + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId2, + storyViewMode: StoryViewModeType.All, + viewDirection: StoryViewDirectionType.Previous, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: { + currentIndex: 0, + messageId: storyId1, + numStories: 2, + shouldShowDetailsModal: false, + storyViewMode: StoryViewModeType.All, + }, + }); + }); + }); + }); + describe('queueStoryDownload', () => { const { queueStoryDownload } = actions;