From 4882248041fd3f84fe44a2b8022f4f4dd62b5214 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Mon, 17 Oct 2022 12:33:07 -0400 Subject: [PATCH] Story viewer navigation improvements --- ts/components/MyStories.tsx | 4 +- ts/components/StoryViewer.tsx | 1 + ts/state/ducks/stories.ts | 226 ++++++-- ts/test-electron/state/ducks/stories_test.ts | 572 +++++++++++++++---- ts/types/Stories.ts | 1 + 5 files changed, 665 insertions(+), 139 deletions(-) diff --git a/ts/components/MyStories.tsx b/ts/components/MyStories.tsx index 98759a7b5cc..f837f60b79d 100644 --- a/ts/components/MyStories.tsx +++ b/ts/components/MyStories.tsx @@ -88,7 +88,7 @@ export const MyStories = ({ onClick={() => viewStory({ storyId: story.messageId, - storyViewMode: StoryViewModeType.User, + storyViewMode: StoryViewModeType.MyStories, }) } type="button" @@ -151,7 +151,7 @@ export const MyStories = ({ onClick: () => { viewStory({ storyId: story.messageId, - storyViewMode: StoryViewModeType.User, + storyViewMode: StoryViewModeType.MyStories, viewTarget: StoryViewTargetType.Details, }); }, diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index 1c4a6f040d8..37bbb488700 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -319,6 +319,7 @@ export const StoryViewer = ({ const canFreelyNavigateStories = storyViewMode === StoryViewModeType.All || storyViewMode === StoryViewModeType.Hidden || + storyViewMode === StoryViewModeType.MyStories || storyViewMode === StoryViewModeType.Unread; const canNavigateLeft = diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index 746bfd6561a..16bd3648f0a 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -82,8 +82,9 @@ export type SelectedStoryDataType = { currentIndex: number; messageId: string; numStories: number; - viewTarget?: StoryViewTargetType; storyViewMode: StoryViewModeType; + unviewedStoryConversationIdsSorted: Array; + viewTarget?: StoryViewTargetType; }; // State @@ -592,6 +593,38 @@ function verifyStoryListMembers( }; } +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< RootStateType, @@ -615,7 +648,7 @@ const getSelectedStoryDataForConversationId = ( ); // Find the index of the storyId provided, or if none provided then find the - // oldest unread story from the user. If all stories are read then we can + // 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; @@ -655,24 +688,23 @@ const getSelectedStoryDataForConversationId = ( export type ViewUserStoriesActionCreatorType = (opts: { conversationId: string; - viewTarget?: StoryViewTargetType; storyViewMode?: StoryViewModeType; + viewTarget?: StoryViewTargetType; }) => unknown; const viewUserStories: ViewUserStoriesActionCreatorType = ({ conversationId, - viewTarget, storyViewMode, + viewTarget, }): ThunkAction => { return (dispatch, getState) => { const { currentIndex, hasUnread, numStories, storiesByConversationId } = getSelectedStoryDataForConversationId(dispatch, getState, conversationId); const story = storiesByConversationId[currentIndex]; + const state = getState(); - const hiddenConversationIds = new Set( - getHideStoryConversationIds(getState()) - ); + const hiddenConversationIds = new Set(getHideStoryConversationIds(state)); let inferredStoryViewMode: StoryViewModeType; if (storyViewMode) { @@ -685,14 +717,30 @@ const viewUserStories: ViewUserStoriesActionCreatorType = ({ 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, - viewTarget, storyViewMode: inferredStoryViewMode, + unviewedStoryConversationIdsSorted, + viewTarget, }, }); }; @@ -732,7 +780,10 @@ const viewStory: ViewStoryActionCreatorType = ( const { viewTarget, storyId, storyViewMode, viewDirection } = opts; const state = getState(); - const { stories } = state.stories; + const { selectedStoryData, stories } = state.stories; + + const unviewedStoryConversationIdsSorted = + selectedStoryData?.unviewedStoryConversationIdsSorted || []; // Spec: // When opening the story viewer you should always be taken to the oldest @@ -754,12 +805,18 @@ const viewStory: ViewStoryActionCreatorType = ( } const { currentIndex, numStories, storiesByConversationId } = - getSelectedStoryDataForConversationId( - dispatch, - getState, - story.conversationId, - storyId - ); + storyViewMode === StoryViewModeType.MyStories + ? getSelectedStoryDataForDistributionListId( + getState, + story.storyDistributionListId, + storyId + ) + : getSelectedStoryDataForConversationId( + dispatch, + getState, + story.conversationId, + storyId + ); // Go directly to the storyId selected if (!viewDirection) { @@ -769,8 +826,101 @@ const viewStory: ViewStoryActionCreatorType = ( currentIndex, messageId: storyId, numStories, + storyViewMode, + unviewedStoryConversationIdsSorted, viewTarget, + }, + }); + 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; @@ -791,6 +941,7 @@ const viewStory: ViewStoryActionCreatorType = ( messageId: nextStory.messageId, numStories, storyViewMode, + unviewedStoryConversationIdsSorted, }, }); return; @@ -808,6 +959,7 @@ const viewStory: ViewStoryActionCreatorType = ( messageId: nextStory.messageId, numStories, storyViewMode, + unviewedStoryConversationIdsSorted, }, }); return; @@ -832,18 +984,27 @@ 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) { - // TODO: DESKTOP-4341 only stories that succeed the current story we're on. - const unreadStory = conversationStories.find( - item => item.storyView.isUnread - ); + // stories first. + if (storyViewMode === StoryViewModeType.Unread) { + const frozenConversationStoryIndex = + unviewedStoryConversationIdsSorted.findIndex( + conversationId => conversationId === story.conversationId + ); - if (unreadStory) { + 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, - unreadStory.conversationId + nextUnreadConversationId ); dispatch({ @@ -856,20 +1017,19 @@ const viewStory: ViewStoryActionCreatorType = ( ].messageId, numStories: nextSelectedStoryData.numStories, storyViewMode, + unviewedStoryConversationIdsSorted, }, }); 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; - } + // 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) { @@ -913,6 +1073,7 @@ const viewStory: ViewStoryActionCreatorType = ( messageId: nextSelectedStoryData.storiesByConversationId[0].messageId, numStories: nextSelectedStoryData.numStories, storyViewMode, + unviewedStoryConversationIdsSorted, }, }); return; @@ -947,6 +1108,7 @@ const viewStory: ViewStoryActionCreatorType = ( messageId: nextSelectedStoryData.storiesByConversationId[0].messageId, numStories: nextSelectedStoryData.numStories, storyViewMode, + unviewedStoryConversationIdsSorted, }, }); return; diff --git a/ts/test-electron/state/ducks/stories_test.ts b/ts/test-electron/state/ducks/stories_test.ts index d88dc5049ed..4804d4e9094 100644 --- a/ts/test-electron/state/ducks/stories_test.ts +++ b/ts/test-electron/state/ducks/stories_test.ts @@ -5,7 +5,6 @@ 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 { DispatchableViewStoryType, @@ -15,6 +14,7 @@ import type { import type { ConversationType } from '../../../state/ducks/conversations'; import type { MessageAttributesType } from '../../../model-types.d'; import type { StateType as RootStateType } from '../../../state/reducer'; +import type { UUIDStringType } from '../../../types/UUID'; import { DAY } from '../../../util/durations'; import { IMAGE_JPEG } from '../../../types/MIME'; import { ReadStatus } from '../../../messages/MessageReadStatus'; @@ -22,6 +22,7 @@ import { StoryViewDirectionType, StoryViewModeType, } from '../../../types/Stories'; +import { UUID } from '../../../types/UUID'; import { actions, getEmptyState, @@ -31,7 +32,6 @@ import { import { noopAction } from '../../../state/ducks/noop'; import { reducer as rootReducer } from '../../../state/reducer'; import { dropNull } from '../../../util/dropNull'; -import type { UUIDStringType } from '../../../types/UUID'; describe('both/state/ducks/stories', () => { const getEmptyRootState = () => ({ @@ -43,7 +43,7 @@ describe('both/state/ducks/stories', () => { const now = Date.now(); return { - conversationId: uuid(), + conversationId: UUID.generate().toString(), id, received_at: now, sent_at: now, @@ -56,7 +56,10 @@ describe('both/state/ducks/stories', () => { function getMockConversation({ id: conversationId, hideStory = false, - }: Pick): ConversationType { + title, + }: Pick & { + title?: string; + }): ConversationType { return { acceptedMessageRequest: true, badges: [], @@ -64,14 +67,15 @@ describe('both/state/ducks/stories', () => { id: conversationId, isMe: false, sharedGroupNames: [], - title: casual.username, + title: title || casual.username, type: 'direct' as const, }; } function getStoryData( messageId: string, - conversationId = uuid() + conversationId = UUID.generate().toString(), + timestampDelta = 0 ): StoryDataType { const now = Date.now(); @@ -81,14 +85,15 @@ describe('both/state/ducks/stories', () => { expireTimer: 1 * DAY, messageId, readStatus: ReadStatus.Unread, - timestamp: now, + timestamp: now - timestampDelta, type: 'story', }; } function getStateFunction( stories: Array, - conversationLookup: { [key: string]: ConversationType } = {} + conversationLookup: { [key: string]: ConversationType } = {}, + unviewedStoryConversationIdsSorted: Array = [] ): () => RootStateType { const rootState = getEmptyRootState(); @@ -100,6 +105,13 @@ describe('both/state/ducks/stories', () => { }, stories: { ...rootState.stories, + selectedStoryData: { + currentIndex: 0, + messageId: '', + numStories: 0, + storyViewMode: StoryViewModeType.Unread, + unviewedStoryConversationIdsSorted, + }, stories, }, }); @@ -121,7 +133,7 @@ describe('both/state/ducks/stories', () => { it('does not find a story', () => { const dispatch = sinon.spy(); viewStory({ - storyId: uuid(), + storyId: UUID.generate().toString(), storyViewMode: StoryViewModeType.All, })(dispatch, getEmptyRootState, null); @@ -132,7 +144,7 @@ describe('both/state/ducks/stories', () => { }); it('selects a specific story', () => { - const storyId = uuid(); + const storyId = UUID.generate().toString(); const getState = getStateFunction([getStoryData(storyId)]); @@ -148,18 +160,19 @@ describe('both/state/ducks/stories', () => { currentIndex: 0, messageId: storyId, numStories: 1, - viewTarget: undefined, storyViewMode: StoryViewModeType.All, + unviewedStoryConversationIdsSorted: [], + viewTarget: undefined, }, }); }); 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 storyId1 = UUID.generate().toString(); + const storyId2 = UUID.generate().toString(); + const storyId3 = UUID.generate().toString(); + const conversationId = UUID.generate().toString(); const getState = getStateFunction([ getStoryData(storyId1, conversationId), getStoryData(storyId2, conversationId), @@ -180,15 +193,16 @@ describe('both/state/ducks/stories', () => { messageId: storyId2, numStories: 3, storyViewMode: StoryViewModeType.User, + unviewedStoryConversationIdsSorted: [], }, }); }); it('selects the prev story', () => { - const storyId1 = uuid(); - const storyId2 = uuid(); - const storyId3 = uuid(); - const conversationId = uuid(); + const storyId1 = UUID.generate().toString(); + const storyId2 = UUID.generate().toString(); + const storyId3 = UUID.generate().toString(); + const conversationId = UUID.generate().toString(); const getState = getStateFunction([ getStoryData(storyId1, conversationId), getStoryData(storyId2, conversationId), @@ -209,15 +223,16 @@ describe('both/state/ducks/stories', () => { messageId: storyId1, numStories: 3, storyViewMode: StoryViewModeType.User, + unviewedStoryConversationIdsSorted: [], }, }); }); 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 storyId1 = UUID.generate().toString(); + const storyId2 = UUID.generate().toString(); + const storyId3 = UUID.generate().toString(); + const conversationId = UUID.generate().toString(); const getState = getStateFunction([ getStoryData(storyId1, conversationId), getStoryData(storyId2, conversationId), @@ -239,32 +254,38 @@ describe('both/state/ducks/stories', () => { }); describe('unviewed stories', () => { - it('finds any unviewed stories and selects them', () => { - const storyId1 = uuid(); - const storyId2 = uuid(); - const storyId3 = uuid(); - - const convoId1 = uuid(); - const convoId2 = uuid(); - const convoId3 = uuid(); + it('does not select hidden stories', () => { + const storyId1 = UUID.generate().toString(); + const storyId2 = UUID.generate().toString(); + const storyId3 = UUID.generate().toString(); + const conversationId = UUID.generate().toString(); + const conversationIdHide = UUID.generate().toString(); const getState = getStateFunction( [ { - ...getStoryData(storyId1, convoId1), + ...getStoryData(storyId1, conversationId), readStatus: ReadStatus.Viewed, }, + + // selector looks up conversation by sourceUuid { - ...getStoryData(storyId2, convoId2), - readStatus: ReadStatus.Viewed, + ...getStoryData(storyId2, conversationIdHide), + sourceUuid: conversationIdHide, + }, + { + ...getStoryData(storyId3, conversationIdHide), + sourceUuid: conversationIdHide, }, - getStoryData(storyId3, convoId3), ], { - [convoId1]: getMockConversation({ id: convoId1 }), - [convoId2]: getMockConversation({ id: convoId2 }), - [convoId3]: getMockConversation({ id: convoId3 }), - } + [conversationId]: getMockConversation({ id: conversationId }), + [conversationIdHide]: getMockConversation({ + id: conversationIdHide, + hideStory: true, + }), + }, + [conversationId] ); const dispatch = sinon.spy(); @@ -276,93 +297,161 @@ describe('both/state/ducks/stories', () => { sinon.assert.calledWith(dispatch, { type: 'stories/VIEW_STORY', - payload: { - currentIndex: 0, - messageId: storyId3, - numStories: 1, - storyViewMode: StoryViewModeType.Unread, - }, + payload: undefined, }); }); - it('does not select hidden stories', () => { - const storyId1 = uuid(); - const storyId2 = uuid(); - const storyId3 = uuid(); - const conversationId = uuid(); - const conversationIdHide: UUIDStringType = 'test-convo-uuid-hide-story'; - + it('does not select stories that precede the currently viewed story', () => { + const storyId1 = UUID.generate().toString(); + const storyId2 = UUID.generate().toString(); + const storyId3 = UUID.generate().toString(); + const storyId4 = UUID.generate().toString(); + const conversationId1 = UUID.generate().toString(); + const conversationId2 = UUID.generate().toString(); + const conversationId3 = UUID.generate().toString(); + + // conversationId3 - storyId4 + // conversationId1 - storyId1, storyId3 + // conversationId2 - storyId2 const getState = getStateFunction( [ + getStoryData(storyId1, conversationId1, 3), { - ...getStoryData(storyId1, conversationId), + ...getStoryData(storyId2, conversationId2, 2), readStatus: ReadStatus.Viewed, }, + getStoryData(storyId3, conversationId1, 1), + getStoryData(storyId4, conversationId3), + ], + { + [conversationId1]: getMockConversation({ id: conversationId1 }), + [conversationId2]: getMockConversation({ id: conversationId2 }), + [conversationId3]: getMockConversation({ id: conversationId3 }), + }, + [conversationId3, conversationId1, conversationId2] + ); - // selector looks up conversation by sourceUuid - { - ...getStoryData(storyId2, conversationIdHide), - sourceUuid: conversationIdHide, - }, + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId2, + storyViewMode: StoryViewModeType.Unread, + viewDirection: StoryViewDirectionType.Next, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: undefined, + }); + }); + + it('correctly goes to previous unviewed story', () => { + const storyId1 = UUID.generate().toString(); + const storyId2 = UUID.generate().toString(); + const storyId3 = UUID.generate().toString(); + const storyId4 = UUID.generate().toString(); + const conversationId1 = UUID.generate().toString(); + const conversationId2 = UUID.generate().toString(); + const conversationId3 = UUID.generate().toString(); + + const unviewedStoryConversationIdsSorted = [ + conversationId3, + conversationId1, + conversationId2, + ]; + + const getState = getStateFunction( + [ + getStoryData(storyId1, conversationId1, 3), { - ...getStoryData(storyId3, conversationIdHide), - sourceUuid: conversationIdHide, + ...getStoryData(storyId2, conversationId2, 2), + readStatus: ReadStatus.Viewed, }, + getStoryData(storyId3, conversationId1, 1), + getStoryData(storyId4, conversationId3), ], { - [conversationId]: getMockConversation({ id: conversationId }), - [conversationIdHide]: getMockConversation({ - id: conversationIdHide, - hideStory: true, - }), - } + [conversationId1]: getMockConversation({ id: conversationId1 }), + [conversationId2]: getMockConversation({ id: conversationId2 }), + [conversationId3]: getMockConversation({ id: conversationId3 }), + }, + unviewedStoryConversationIdsSorted ); const dispatch = sinon.spy(); viewStory({ - storyId: storyId1, + storyId: storyId2, storyViewMode: StoryViewModeType.Unread, - viewDirection: StoryViewDirectionType.Next, + viewDirection: StoryViewDirectionType.Previous, })(dispatch, getState, null); sinon.assert.calledWith(dispatch, { type: 'stories/VIEW_STORY', - payload: undefined, + payload: { + currentIndex: 0, + messageId: storyId1, + numStories: 2, + storyViewMode: StoryViewModeType.Unread, + unviewedStoryConversationIdsSorted, + }, }); }); - // TODO: DESKTOP-4341 - removed until implemented - /* - 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), - ]); + it('does not close the viewer when playing the next story', () => { + const storyId1 = UUID.generate().toString(); + const storyId2 = UUID.generate().toString(); + const storyId3 = UUID.generate().toString(); + const storyId4 = UUID.generate().toString(); + const conversationId1 = UUID.generate().toString(); + const conversationId2 = UUID.generate().toString(); + const conversationId3 = UUID.generate().toString(); + const unviewedStoryConversationIdsSorted = [ + conversationId3, + conversationId2, + conversationId1, + ]; + const getState = getStateFunction( + [ + getStoryData(storyId1, conversationId2, 3), + getStoryData(storyId2, conversationId1, 2), + getStoryData(storyId3, conversationId2, 1), + { + ...getStoryData(storyId4, conversationId3), + readStatus: ReadStatus.Viewed, + }, + ], + { + [conversationId1]: getMockConversation({ id: conversationId1 }), + [conversationId2]: getMockConversation({ id: conversationId2 }), + [conversationId3]: getMockConversation({ id: conversationId3 }), + }, + unviewedStoryConversationIdsSorted + ); const dispatch = sinon.spy(); viewStory({ - storyId: storyId3, + storyId: storyId4, storyViewMode: StoryViewModeType.Unread, viewDirection: StoryViewDirectionType.Next, })(dispatch, getState, null); sinon.assert.calledWith(dispatch, { type: 'stories/VIEW_STORY', - payload: undefined, + payload: { + currentIndex: 0, + messageId: storyId1, + numStories: 2, + storyViewMode: StoryViewModeType.Unread, + unviewedStoryConversationIdsSorted, + }, }); }); - */ it('closes the viewer when there are no more unviewed stories', () => { - const storyId1 = uuid(); - const storyId2 = uuid(); + const storyId1 = UUID.generate().toString(); + const storyId2 = UUID.generate().toString(); - const conversationId1 = uuid(); - const conversationId2 = uuid(); + const conversationId1 = UUID.generate().toString(); + const conversationId2 = UUID.generate().toString(); const getState = getStateFunction( [ @@ -378,7 +467,8 @@ describe('both/state/ducks/stories', () => { { [conversationId1]: getMockConversation({ id: conversationId1 }), [conversationId2]: getMockConversation({ id: conversationId2 }), - } + }, + [conversationId1] ); const dispatch = sinon.spy(); @@ -395,10 +485,280 @@ describe('both/state/ducks/stories', () => { }); }); + describe('paging through sent stories', () => { + function getSentStoryReduxData() { + const distributionListId1 = UUID.generate().toString(); + const distributionListId2 = UUID.generate().toString(); + const storyDistributionLists = { + distributionLists: [ + { + id: distributionListId1, + name: 'List 1', + allowsReplies: true, + isBlockList: false, + memberUuids: [ + UUID.generate().toString(), + UUID.generate().toString(), + UUID.generate().toString(), + ], + }, + { + id: distributionListId2, + name: 'List 2', + allowsReplies: true, + isBlockList: false, + memberUuids: [ + UUID.generate().toString(), + UUID.generate().toString(), + UUID.generate().toString(), + ], + }, + ], + }; + + const ourConversationId = UUID.generate().toString(); + const groupConversationId = UUID.generate().toString(); + + function getMyStoryData( + messageId: string, + storyDistributionListId?: string, + timestampDelta = 0 + ): StoryDataType { + const now = Date.now(); + + return { + conversationId: storyDistributionListId + ? ourConversationId + : groupConversationId, + expirationStartTimestamp: now, + expireTimer: 1 * DAY, + messageId, + readStatus: ReadStatus.Unread, + sendStateByConversationId: {}, + storyDistributionListId, + timestamp: now - timestampDelta, + type: 'story', + }; + } + + const storyId1 = UUID.generate().toString(); + const storyId2 = UUID.generate().toString(); + const storyId3 = UUID.generate().toString(); + const storyId4 = UUID.generate().toString(); + const storyId5 = UUID.generate().toString(); + const myStories = [ + getMyStoryData(storyId1, distributionListId1, 5), + getMyStoryData(storyId2, distributionListId2, 4), + getMyStoryData(storyId3, distributionListId1, 3), + getMyStoryData(storyId4, undefined, 2), // group story + getMyStoryData(storyId5, distributionListId2, 1), + ]; + + const rootState = getEmptyRootState(); + + return { + storyId1, + storyId2, + storyId3, + storyId4, + storyId5, + + getState: () => ({ + ...rootState, + conversations: { + ...rootState.conversations, + conversationLookup: { + [groupConversationId]: getMockConversation({ + id: groupConversationId, + title: 'Group', + }), + }, + }, + storyDistributionLists, + stories: { + ...rootState.stories, + stories: myStories, + }, + }), + }; + } + + it('closes the viewer when hitting next at the last item', () => { + const { getState, ...reduxData } = getSentStoryReduxData(); + const { storyId3 } = reduxData; + + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId3, + storyViewMode: StoryViewModeType.MyStories, + viewDirection: StoryViewDirectionType.Next, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: undefined, + }); + }); + + it('closes the viewer when hitting prev at the first item', () => { + const { getState, ...reduxData } = getSentStoryReduxData(); + const { storyId2 } = reduxData; + + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId2, + storyViewMode: StoryViewModeType.MyStories, + viewDirection: StoryViewDirectionType.Previous, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: undefined, + }); + }); + + it('goes to next story within a distribution list', () => { + const { getState, ...reduxData } = getSentStoryReduxData(); + const { storyId1, storyId3 } = reduxData; + + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId1, + storyViewMode: StoryViewModeType.MyStories, + viewDirection: StoryViewDirectionType.Next, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: { + currentIndex: 1, + messageId: storyId3, + numStories: 2, + storyViewMode: StoryViewModeType.MyStories, + unviewedStoryConversationIdsSorted: [], + }, + }); + }); + + it('goes to prev story within a distribution list', () => { + const { getState, ...reduxData } = getSentStoryReduxData(); + const { storyId1, storyId3 } = reduxData; + + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId3, + storyViewMode: StoryViewModeType.MyStories, + viewDirection: StoryViewDirectionType.Previous, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: { + currentIndex: 0, + messageId: storyId1, + numStories: 2, + storyViewMode: StoryViewModeType.MyStories, + unviewedStoryConversationIdsSorted: [], + }, + }); + }); + + it('goes to the next distribution list', () => { + const { getState, storyId4, storyId1 } = getSentStoryReduxData(); + + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId4, + storyViewMode: StoryViewModeType.MyStories, + viewDirection: StoryViewDirectionType.Next, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: { + currentIndex: 0, + messageId: storyId1, + numStories: 2, + storyViewMode: StoryViewModeType.MyStories, + unviewedStoryConversationIdsSorted: [], + }, + }); + }); + + it('goes to the prev distribution list', () => { + const { getState, ...reduxData } = getSentStoryReduxData(); + const { storyId4, storyId5 } = reduxData; + + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId4, + storyViewMode: StoryViewModeType.MyStories, + viewDirection: StoryViewDirectionType.Previous, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: { + currentIndex: 1, + messageId: storyId5, + numStories: 2, + storyViewMode: StoryViewModeType.MyStories, + unviewedStoryConversationIdsSorted: [], + }, + }); + }); + + it('goes next to a group story', () => { + const { getState, ...reduxData } = getSentStoryReduxData(); + const { storyId4, storyId5 } = reduxData; + + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId5, + storyViewMode: StoryViewModeType.MyStories, + viewDirection: StoryViewDirectionType.Next, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: { + currentIndex: 0, + messageId: storyId4, + numStories: 1, + storyViewMode: StoryViewModeType.MyStories, + unviewedStoryConversationIdsSorted: [], + }, + }); + }); + + it('goes prev to a group story', () => { + const { getState, ...reduxData } = getSentStoryReduxData(); + const { storyId1, storyId4 } = reduxData; + + const dispatch = sinon.spy(); + viewStory({ + storyId: storyId1, + storyViewMode: StoryViewModeType.MyStories, + viewDirection: StoryViewDirectionType.Previous, + })(dispatch, getState, null); + + sinon.assert.calledWith(dispatch, { + type: 'stories/VIEW_STORY', + payload: { + currentIndex: 0, + messageId: storyId4, + numStories: 1, + storyViewMode: StoryViewModeType.MyStories, + unviewedStoryConversationIdsSorted: [], + }, + }); + }); + }); + describe('paging through collections of stories', () => { function getViewedStoryData( storyId: string, - conversationId?: string + conversationId?: UUIDStringType ): StoryDataType { return { ...getStoryData(storyId, conversationId), @@ -407,11 +767,11 @@ describe('both/state/ducks/stories', () => { } it("goes to the next user's stories", () => { - const storyId1 = uuid(); - const storyId2 = uuid(); - const storyId3 = uuid(); - const conversationId2 = uuid(); - const conversationId1 = uuid(); + const storyId1 = UUID.generate().toString(); + const storyId2 = UUID.generate().toString(); + const storyId3 = UUID.generate().toString(); + const conversationId2 = UUID.generate().toString(); + const conversationId1 = UUID.generate().toString(); const getState = getStateFunction( [ getViewedStoryData(storyId1, conversationId1), @@ -438,16 +798,17 @@ describe('both/state/ducks/stories', () => { messageId: storyId2, numStories: 2, storyViewMode: StoryViewModeType.All, + unviewedStoryConversationIdsSorted: [], }, }); }); it("goes to the prev user's stories", () => { - const storyId1 = uuid(); - const storyId2 = uuid(); - const storyId3 = uuid(); - const conversationId1 = uuid(); - const conversationId2 = uuid(); + const storyId1 = UUID.generate().toString(); + const storyId2 = UUID.generate().toString(); + const storyId3 = UUID.generate().toString(); + const conversationId1 = UUID.generate().toString(); + const conversationId2 = UUID.generate().toString(); const getState = getStateFunction( [ getViewedStoryData(storyId1, conversationId2), @@ -474,6 +835,7 @@ describe('both/state/ducks/stories', () => { messageId: storyId1, numStories: 2, storyViewMode: StoryViewModeType.All, + unviewedStoryConversationIdsSorted: [], }, }); }); @@ -484,7 +846,7 @@ describe('both/state/ducks/stories', () => { const { queueStoryDownload } = actions; it('no attachment, no dispatch', async function test() { - const storyId = uuid(); + const storyId = UUID.generate().toString(); const messageAttributes = getStoryMessage(storyId); window.MessageController.register(storyId, messageAttributes); @@ -496,13 +858,13 @@ describe('both/state/ducks/stories', () => { }); it('downloading, no dispatch', async function test() { - const storyId = uuid(); + const storyId = UUID.generate().toString(); const messageAttributes = { ...getStoryMessage(storyId), attachments: [ { contentType: IMAGE_JPEG, - downloadJobId: uuid(), + downloadJobId: UUID.generate().toString(), pending: true, size: 0, }, @@ -518,7 +880,7 @@ describe('both/state/ducks/stories', () => { }); it('downloaded, no dispatch', async function test() { - const storyId = uuid(); + const storyId = UUID.generate().toString(); const messageAttributes = { ...getStoryMessage(storyId), attachments: [ @@ -540,7 +902,7 @@ describe('both/state/ducks/stories', () => { }); it('downloaded, but unresolved, we should resolve the path', async function test() { - const storyId = uuid(); + const storyId = UUID.generate().toString(); const attachment = { contentType: IMAGE_JPEG, path: 'image.jpg', @@ -620,7 +982,7 @@ describe('both/state/ducks/stories', () => { }); it('not downloaded, queued for download', async function test() { - const storyId = uuid(); + const storyId = UUID.generate().toString(); const messageAttributes = { ...getStoryMessage(storyId), attachments: [ diff --git a/ts/types/Stories.ts b/ts/types/Stories.ts index 4c2c14d323c..48b1d6ba934 100644 --- a/ts/types/Stories.ts +++ b/ts/types/Stories.ts @@ -127,6 +127,7 @@ export enum StoryViewTargetType { export enum StoryViewModeType { All = 'All', Hidden = 'Hidden', + MyStories = 'MyStories', Single = 'Single', Unread = 'Unread', User = 'User',