Fixes story play order

This commit is contained in:
Josh Perez 2022-09-21 20:55:23 -04:00 committed by GitHub
parent 4308739bc0
commit 0be580e8e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 471 additions and 30 deletions

View file

@ -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<void, RootStateType, unknown, ViewStoryActionType>;
const viewStory: ViewStoryActionCreatorType = (
opts
): ThunkAction<void, RootStateType, unknown, ViewStoryActionType> => {
@ -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: {

View file

@ -1050,3 +1050,11 @@ export const getConversationsStoppingSend = createSelector(
return sortByTitle(conversations);
}
);
export const getHideStoryConversationIds = createSelector(
getConversationLookup,
(conversationLookup): Array<string> =>
Object.keys(conversationLookup).filter(
conversationId => conversationLookup[conversationId].hideStory
)
);

View file

@ -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;

View file

@ -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, 'id' | 'hideStory'>): 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<StoryDataType>,
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;