signal-desktop/ts/state/ducks/stories.ts

505 lines
12 KiB
TypeScript
Raw Normal View History

2022-03-04 21:14:52 +00:00
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ThunkAction } from 'redux-thunk';
import { pick } from 'lodash';
import type { AttachmentType } from '../../types/Attachment';
import type { BodyRangeType } from '../../types/Util';
import type { MessageAttributesType } from '../../model-types.d';
2022-04-15 00:08:46 +00:00
import type {
MessageChangedActionType,
MessageDeletedActionType,
} from './conversations';
2022-03-04 21:14:52 +00:00
import type { NoopActionType } from './noop';
import type { StateType as RootStateType } from '../reducer';
import type { StoryViewType } from '../../components/StoryListItem';
import type { SyncType } from '../../jobs/helpers/syncHelpers';
import * as log from '../../logging/log';
2022-03-29 01:10:08 +00:00
import dataInterface from '../../sql/Client';
2022-03-04 21:14:52 +00:00
import { ReadStatus } from '../../messages/MessageReadStatus';
import { ToastReactionFailed } from '../../components/ToastReactionFailed';
2022-03-29 01:10:08 +00:00
import { UUID } from '../../types/UUID';
2022-03-04 21:14:52 +00:00
import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend';
import { getMessageById } from '../../messages/getMessageById';
import { markViewed } from '../../services/MessageUpdater';
2022-03-29 01:10:08 +00:00
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
import { replaceIndex } from '../../util/replaceIndex';
2022-03-04 21:14:52 +00:00
import { showToast } from '../../util/showToast';
2022-03-29 01:10:08 +00:00
import { isDownloaded, isDownloading } from '../../types/Attachment';
2022-03-04 21:14:52 +00:00
import { useBoundActions } from '../../hooks/useBoundActions';
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
export type StoryDataType = {
attachment?: AttachmentType;
messageId: string;
selectedReaction?: string;
} & Pick<
MessageAttributesType,
2022-04-15 00:08:46 +00:00
| 'conversationId'
| 'deletedForEveryone'
| 'readStatus'
| 'sendStateByConversationId'
| 'source'
| 'sourceUuid'
| 'timestamp'
| 'type'
2022-03-04 21:14:52 +00:00
>;
// State
export type StoriesStateType = {
readonly isShowingStoriesView: boolean;
2022-04-15 00:08:46 +00:00
readonly replyState?: {
messageId: string;
replies: Array<MessageAttributesType>;
};
2022-03-04 21:14:52 +00:00
readonly stories: Array<StoryDataType>;
};
// Actions
2022-04-15 00:08:46 +00:00
const LOAD_STORY_REPLIES = 'stories/LOAD_STORY_REPLIES';
2022-03-29 01:10:08 +00:00
const MARK_STORY_READ = 'stories/MARK_STORY_READ';
2022-03-04 21:14:52 +00:00
const REACT_TO_STORY = 'stories/REACT_TO_STORY';
2022-04-15 00:08:46 +00:00
const REPLY_TO_STORY = 'stories/REPLY_TO_STORY';
2022-03-29 01:10:08 +00:00
const STORY_CHANGED = 'stories/STORY_CHANGED';
2022-03-04 21:14:52 +00:00
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
2022-04-15 00:08:46 +00:00
type LoadStoryRepliesActionType = {
type: typeof LOAD_STORY_REPLIES;
payload: {
messageId: string;
replies: Array<MessageAttributesType>;
};
};
2022-03-29 01:10:08 +00:00
type MarkStoryReadActionType = {
type: typeof MARK_STORY_READ;
payload: string;
2022-03-04 21:14:52 +00:00
};
type ReactToStoryActionType = {
type: typeof REACT_TO_STORY;
payload: {
messageId: string;
selectedReaction: string;
};
};
2022-04-15 00:08:46 +00:00
type ReplyToStoryActionType = {
type: typeof REPLY_TO_STORY;
payload: MessageAttributesType;
};
2022-03-29 01:10:08 +00:00
type StoryChangedActionType = {
type: typeof STORY_CHANGED;
payload: StoryDataType;
};
2022-03-04 21:14:52 +00:00
type ToggleViewActionType = {
type: typeof TOGGLE_VIEW;
};
export type StoriesActionType =
2022-04-15 00:08:46 +00:00
| LoadStoryRepliesActionType
2022-03-29 01:10:08 +00:00
| MarkStoryReadActionType
2022-04-15 00:08:46 +00:00
| MessageChangedActionType
2022-03-04 21:14:52 +00:00
| MessageDeletedActionType
| ReactToStoryActionType
2022-04-15 00:08:46 +00:00
| ReplyToStoryActionType
2022-03-29 01:10:08 +00:00
| StoryChangedActionType
2022-03-04 21:14:52 +00:00
| ToggleViewActionType;
// Action Creators
export const actions = {
2022-04-15 00:08:46 +00:00
loadStoryReplies,
2022-03-04 21:14:52 +00:00
markStoryRead,
2022-03-29 01:10:08 +00:00
queueStoryDownload,
2022-03-04 21:14:52 +00:00
reactToStory,
replyToStory,
2022-03-29 01:10:08 +00:00
storyChanged,
2022-03-04 21:14:52 +00:00
toggleStoriesView,
};
export const useStoriesActions = (): typeof actions => useBoundActions(actions);
2022-04-15 00:08:46 +00:00
function loadStoryReplies(
conversationId: string,
messageId: string
): ThunkAction<void, RootStateType, unknown, LoadStoryRepliesActionType> {
return async dispatch => {
const replies = await dataInterface.getOlderMessagesByConversation(
conversationId,
{ limit: 9000, storyId: messageId }
);
dispatch({
type: LOAD_STORY_REPLIES,
payload: {
messageId,
replies,
},
});
};
}
2022-03-04 21:14:52 +00:00
function markStoryRead(
messageId: string
2022-03-29 01:10:08 +00:00
): ThunkAction<void, RootStateType, unknown, MarkStoryReadActionType> {
2022-03-04 21:14:52 +00:00
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;
}
2022-04-08 15:40:15 +00:00
if (!isDownloaded(matchingStory.attachment)) {
return;
}
2022-03-04 21:14:52 +00:00
if (matchingStory.readStatus !== ReadStatus.Unread) {
return;
}
const message = await getMessageById(messageId);
if (!message) {
return;
}
2022-03-29 01:10:08 +00:00
const storyReadDate = Date.now();
markViewed(message.attributes, storyReadDate);
2022-03-04 21:14:52 +00:00
const viewedReceipt = {
messageId,
senderE164: message.attributes.source,
senderUuid: message.attributes.sourceUuid,
timestamp: message.attributes.sent_at,
};
const viewSyncs: Array<SyncType> = [viewedReceipt];
if (!window.ConversationController.areWePrimaryDevice()) {
viewSyncJobQueue.add({ viewSyncs });
}
viewedReceiptsJobQueue.add({ viewedReceipt });
2022-03-29 01:10:08 +00:00
await dataInterface.addNewStoryRead({
authorId: message.attributes.sourceUuid,
conversationId: message.attributes.conversationId,
storyId: new UUID(messageId).toString(),
storyReadDate,
});
dispatch({
type: MARK_STORY_READ,
payload: messageId,
});
};
}
function queueStoryDownload(
storyId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const story = await getMessageById(storyId);
if (!story) {
return;
}
const storyAttributes: MessageAttributesType = story.attributes;
const { attachments } = storyAttributes;
const attachment = attachments && attachments[0];
if (!attachment) {
log.warn('queueStoryDownload: No attachment found for story', {
storyId,
});
return;
}
if (isDownloaded(attachment)) {
return;
}
if (isDownloading(attachment)) {
return;
}
// We want to ensure that we re-hydrate the story reply context with the
// completed attachment download.
story.set({ storyReplyContext: undefined });
await queueAttachmentDownloads(story.attributes);
2022-03-04 21:14:52 +00:00
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function reactToStory(
nextReaction: string,
messageId: string,
previousReaction?: string
): ThunkAction<void, RootStateType, unknown, ReactToStoryActionType> {
return async dispatch => {
try {
await enqueueReactionForSend({
messageId,
emoji: nextReaction,
remove: nextReaction === previousReaction,
});
dispatch({
type: REACT_TO_STORY,
payload: {
messageId,
selectedReaction: nextReaction,
},
});
} catch (error) {
log.error('Error enqueuing reaction', error, messageId, nextReaction);
showToast(ToastReactionFailed);
}
};
}
function replyToStory(
conversationId: string,
messageBody: string,
2022-03-04 21:14:52 +00:00
mentions: Array<BodyRangeType>,
timestamp: number,
story: StoryViewType
2022-04-15 00:08:46 +00:00
): ThunkAction<void, RootStateType, unknown, ReplyToStoryActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
log.error('replyToStory: conversation does not exist', conversationId);
return;
}
2022-03-04 21:14:52 +00:00
2022-04-15 00:08:46 +00:00
const messageAttributes = await conversation.enqueueMessageForSend(
{
body: messageBody,
attachments: [],
mentions,
},
2022-03-04 21:14:52 +00:00
{
storyId: story.messageId,
timestamp,
}
);
2022-04-15 00:08:46 +00:00
if (messageAttributes) {
dispatch({
type: REPLY_TO_STORY,
payload: messageAttributes,
});
}
2022-03-04 21:14:52 +00:00
};
}
2022-03-29 01:10:08 +00:00
function storyChanged(story: StoryDataType): StoryChangedActionType {
return {
type: STORY_CHANGED,
payload: story,
};
}
2022-03-04 21:14:52 +00:00
function toggleStoriesView(): ToggleViewActionType {
return {
type: TOGGLE_VIEW,
};
}
// Reducer
export function getEmptyState(
overrideState: Partial<StoriesStateType> = {}
): StoriesStateType {
return {
isShowingStoriesView: false,
stories: [],
...overrideState,
};
}
export function reducer(
state: Readonly<StoriesStateType> = getEmptyState(),
action: Readonly<StoriesActionType>
): StoriesStateType {
if (action.type === TOGGLE_VIEW) {
return {
...state,
isShowingStoriesView: !state.isShowingStoriesView,
};
}
if (action.type === 'MESSAGE_DELETED') {
2022-04-15 00:08:46 +00:00
const nextStories = state.stories.filter(
story => story.messageId !== action.payload.id
);
if (nextStories.length === state.stories.length) {
return state;
}
2022-03-04 21:14:52 +00:00
return {
...state,
2022-04-15 00:08:46 +00:00
stories: nextStories,
2022-03-04 21:14:52 +00:00
};
}
2022-03-29 01:10:08 +00:00
if (action.type === STORY_CHANGED) {
2022-03-04 21:14:52 +00:00
const newStory = pick(action.payload, [
'attachment',
'conversationId',
2022-04-15 00:08:46 +00:00
'deletedForEveryone',
2022-03-04 21:14:52 +00:00
'messageId',
'readStatus',
'selectedReaction',
2022-04-15 00:08:46 +00:00
'sendStateByConversationId',
2022-03-04 21:14:52 +00:00
'source',
'sourceUuid',
'timestamp',
2022-04-15 00:08:46 +00:00
'type',
2022-03-04 21:14:52 +00:00
]);
2022-04-21 00:29:37 +00:00
const prevStoryIndex = state.stories.findIndex(
2022-03-04 21:14:52 +00:00
existingStory => existingStory.messageId === newStory.messageId
);
2022-04-21 00:29:37 +00:00
if (prevStoryIndex >= 0) {
const prevStory = state.stories[prevStoryIndex];
2022-03-29 01:10:08 +00:00
2022-04-21 00:29:37 +00:00
// Stories rarely need to change, here are the following exceptions:
const isDownloadingAttachment = isDownloading(newStory.attachment);
const hasAttachmentDownloaded =
!isDownloaded(prevStory.attachment) &&
isDownloaded(newStory.attachment);
const readStatusChanged = prevStory.readStatus !== newStory.readStatus;
2022-03-29 01:10:08 +00:00
2022-04-21 00:29:37 +00:00
const shouldReplace =
isDownloadingAttachment || hasAttachmentDownloaded || readStatusChanged;
if (!shouldReplace) {
2022-04-15 00:08:46 +00:00
return state;
}
2022-03-29 01:10:08 +00:00
return {
...state,
2022-04-21 00:29:37 +00:00
stories: replaceIndex(state.stories, prevStoryIndex, newStory),
2022-03-29 01:10:08 +00:00
};
2022-03-04 21:14:52 +00:00
}
2022-04-21 00:29:37 +00:00
// Adding a new story
2022-03-29 01:10:08 +00:00
const stories = [...state.stories, newStory].sort((a, b) =>
a.timestamp > b.timestamp ? 1 : -1
2022-03-04 21:14:52 +00:00
);
return {
...state,
stories,
};
}
if (action.type === REACT_TO_STORY) {
return {
...state,
stories: state.stories.map(story => {
if (story.messageId === action.payload.messageId) {
return {
...story,
selectedReaction: action.payload.selectedReaction,
};
}
return story;
}),
};
}
2022-03-29 01:10:08 +00:00
if (action.type === MARK_STORY_READ) {
return {
...state,
stories: state.stories.map(story => {
if (story.messageId === action.payload) {
return {
...story,
readStatus: ReadStatus.Viewed,
};
}
return story;
}),
};
}
2022-04-15 00:08:46 +00:00
if (action.type === LOAD_STORY_REPLIES) {
return {
...state,
replyState: action.payload,
};
}
// For live updating of the story replies
if (
action.type === 'MESSAGE_CHANGED' &&
state.replyState &&
state.replyState.messageId === action.payload.data.storyId
) {
const { replyState } = state;
const messageIndex = replyState.replies.findIndex(
reply => reply.id === action.payload.id
);
// New message
if (messageIndex < 0) {
return {
...state,
replyState: {
messageId: replyState.messageId,
replies: [...replyState.replies, action.payload.data],
},
};
}
// Changed message, also handles DOE
return {
...state,
replyState: {
messageId: replyState.messageId,
replies: replaceIndex(
replyState.replies,
messageIndex,
action.payload.data
),
},
};
}
if (action.type === REPLY_TO_STORY) {
const { replyState } = state;
if (!replyState) {
return state;
}
return {
...state,
replyState: {
messageId: replyState.messageId,
replies: [...replyState.replies, action.payload],
},
};
}
2022-03-04 21:14:52 +00:00
return state;
}