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

340 lines
8.6 KiB
TypeScript
Raw Normal View History

2022-03-04 21:14:52 +00:00
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createSelector } from 'reselect';
import { pick } from 'lodash';
2022-04-15 00:08:46 +00:00
import type { GetConversationByIdType } from './conversations';
2022-04-28 22:06:28 +00:00
import type { ConversationType } from '../ducks/conversations';
2022-07-01 00:52:03 +00:00
import type { MessageReactionType } from '../../model-types.d';
2022-03-04 21:14:52 +00:00
import type {
ConversationStoryType,
2022-07-01 00:52:03 +00:00
MyStoryType,
ReplyStateType,
StorySendStateType,
2022-03-04 21:14:52 +00:00
StoryViewType,
2022-07-01 00:52:03 +00:00
} from '../../types/Stories';
2022-03-04 21:14:52 +00:00
import type { StateType } from '../reducer';
2022-07-06 19:06:20 +00:00
import type {
SelectedStoryDataType,
StoryDataType,
StoriesStateType,
} from '../ducks/stories';
import { MY_STORIES_ID } from '../../types/Stories';
2022-03-04 21:14:52 +00:00
import { ReadStatus } from '../../messages/MessageReadStatus';
2022-07-01 00:52:03 +00:00
import { SendStatus } from '../../messages/MessageSendState';
2022-04-15 00:08:46 +00:00
import { canReply } from './message';
import {
getContactNameColorSelector,
getConversationSelector,
getMe,
} from './conversations';
2022-07-01 00:52:03 +00:00
import { getDistributionListSelector } from './storyDistributionLists';
2022-03-04 21:14:52 +00:00
export const getStoriesState = (state: StateType): StoriesStateType =>
state.stories;
export const shouldShowStoriesView = createSelector(
getStoriesState,
({ isShowingStoriesView }): boolean => isShowingStoriesView
);
2022-07-06 19:06:20 +00:00
export const getSelectedStoryData = createSelector(
getStoriesState,
({ selectedStoryData }): SelectedStoryDataType | undefined =>
selectedStoryData
);
function getReactionUniqueId(reaction: MessageReactionType): string {
return `${reaction.fromId}:${reaction.targetAuthorUuid}:${reaction.timestamp}`;
2022-04-08 15:40:15 +00:00
}
function sortByRecencyAndUnread(
2022-07-06 19:06:20 +00:00
storyA: ConversationStoryType,
storyB: ConversationStoryType
2022-04-08 15:40:15 +00:00
): number {
2022-07-06 19:06:20 +00:00
if (storyA.storyView.isUnread && storyB.storyView.isUnread) {
return storyA.storyView.timestamp > storyB.storyView.timestamp ? -1 : 1;
2022-04-08 15:40:15 +00:00
}
2022-07-06 19:06:20 +00:00
if (storyB.storyView.isUnread) {
2022-04-08 15:40:15 +00:00
return 1;
}
2022-07-06 19:06:20 +00:00
if (storyA.storyView.isUnread) {
2022-04-08 15:40:15 +00:00
return -1;
}
2022-07-06 19:06:20 +00:00
return storyA.storyView.timestamp > storyB.storyView.timestamp ? -1 : 1;
2022-04-28 22:06:28 +00:00
}
function getAvatarData(
conversation: ConversationType
): Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'isMe'
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
> {
return pick(conversation, [
'acceptedMessageRequest',
'avatarPath',
'color',
'isMe',
'name',
'profileName',
'sharedGroupNames',
'title',
]);
}
2022-07-06 19:06:20 +00:00
export function getStoryView(
2022-04-15 00:08:46 +00:00
conversationSelector: GetConversationByIdType,
2022-07-06 19:06:20 +00:00
story: StoryDataType
2022-07-01 00:52:03 +00:00
): StoryViewType {
2022-04-15 00:08:46 +00:00
const sender = pick(conversationSelector(story.sourceUuid || story.source), [
'acceptedMessageRequest',
'avatarPath',
'color',
'firstName',
'hideStory',
'id',
'isMe',
'name',
'profileName',
'sharedGroupNames',
'title',
]);
2022-07-01 00:52:03 +00:00
const { attachment, timestamp } = pick(story, ['attachment', 'timestamp']);
return {
attachment,
2022-07-06 19:06:20 +00:00
canReply: canReply(story, undefined, conversationSelector),
2022-07-01 00:52:03 +00:00
isUnread: story.readStatus === ReadStatus.Unread,
messageId: story.messageId,
sender,
timestamp,
};
}
2022-07-06 19:06:20 +00:00
export function getConversationStory(
2022-07-01 00:52:03 +00:00
conversationSelector: GetConversationByIdType,
2022-07-06 19:06:20 +00:00
story: StoryDataType
2022-07-01 00:52:03 +00:00
): ConversationStoryType {
const sender = pick(conversationSelector(story.sourceUuid || story.source), [
'hideStory',
'id',
]);
2022-04-15 00:08:46 +00:00
const conversation = pick(conversationSelector(story.conversationId), [
'acceptedMessageRequest',
'avatarPath',
'color',
'id',
'name',
'profileName',
'sharedGroupNames',
'title',
]);
2022-07-06 19:06:20 +00:00
const storyView = getStoryView(conversationSelector, story);
2022-04-15 00:08:46 +00:00
return {
conversationId: conversation.id,
group: conversation.id !== sender.id ? conversation : undefined,
isHidden: Boolean(sender.hideStory),
2022-07-06 19:06:20 +00:00
storyView,
2022-04-15 00:08:46 +00:00
};
}
export const getStoryReplies = createSelector(
getConversationSelector,
getContactNameColorSelector,
getMe,
getStoriesState,
(
conversationSelector,
contactNameColorSelector,
me,
2022-04-28 22:06:28 +00:00
{ stories, replyState }: Readonly<StoriesStateType>
2022-04-15 00:08:46 +00:00
): ReplyStateType | undefined => {
if (!replyState) {
return;
}
2022-04-28 22:06:28 +00:00
const foundStory = stories.find(
story => story.messageId === replyState.messageId
);
const reactions = foundStory
? (foundStory.reactions || []).map(reaction => {
const conversation = conversationSelector(reaction.fromId);
return {
...getAvatarData(conversation),
contactNameColor: contactNameColorSelector(
foundStory.conversationId,
conversation.id
),
id: getReactionUniqueId(reaction),
reactionEmoji: reaction.emoji,
timestamp: reaction.timestamp,
};
})
: [];
const replies = replyState.replies.map(reply => {
const conversation =
reply.type === 'outgoing'
? me
: conversationSelector(reply.sourceUuid || reply.source);
return {
...getAvatarData(conversation),
...pick(reply, ['body', 'deletedForEveryone', 'id', 'timestamp']),
contactNameColor: contactNameColorSelector(
reply.conversationId,
conversation.id
),
};
});
const combined = [...replies, ...reactions].sort((a, b) =>
a.timestamp > b.timestamp ? 1 : -1
);
2022-04-15 00:08:46 +00:00
return {
messageId: replyState.messageId,
2022-04-28 22:06:28 +00:00
replies: combined,
2022-04-15 00:08:46 +00:00
};
}
);
2022-03-04 21:14:52 +00:00
export const getStories = createSelector(
getConversationSelector,
2022-07-01 00:52:03 +00:00
getDistributionListSelector,
2022-03-04 21:14:52 +00:00
getStoriesState,
2022-03-29 01:10:08 +00:00
shouldShowStoriesView,
2022-03-04 21:14:52 +00:00
(
conversationSelector,
2022-07-01 00:52:03 +00:00
distributionListSelector,
2022-03-29 01:10:08 +00:00
{ stories }: Readonly<StoriesStateType>,
isShowingStoriesView
2022-03-04 21:14:52 +00:00
): {
hiddenStories: Array<ConversationStoryType>;
2022-07-01 00:52:03 +00:00
myStories: Array<MyStoryType>;
2022-03-04 21:14:52 +00:00
stories: Array<ConversationStoryType>;
} => {
2022-03-29 01:10:08 +00:00
if (!isShowingStoriesView) {
return {
hiddenStories: [],
2022-07-01 00:52:03 +00:00
myStories: [],
2022-03-29 01:10:08 +00:00
stories: [],
};
}
2022-03-04 21:14:52 +00:00
const hiddenStoriesById = new Map<string, ConversationStoryType>();
2022-07-01 00:52:03 +00:00
const myStoriesById = new Map<string, MyStoryType>();
const storiesById = new Map<string, ConversationStoryType>();
2022-03-04 21:14:52 +00:00
stories.forEach(story => {
2022-07-01 00:52:03 +00:00
if (story.deletedForEveryone) {
return;
}
if (story.sendStateByConversationId && story.storyDistributionListId) {
2022-07-06 19:06:20 +00:00
const list =
story.storyDistributionListId === MY_STORIES_ID
? { id: MY_STORIES_ID, name: MY_STORIES_ID }
: distributionListSelector(story.storyDistributionListId);
2022-07-01 00:52:03 +00:00
if (!list) {
return;
}
2022-07-06 19:06:20 +00:00
const storyView = getStoryView(conversationSelector, story);
2022-07-01 00:52:03 +00:00
const sendState: Array<StorySendStateType> = [];
const { sendStateByConversationId } = story;
let views = 0;
Object.keys(story.sendStateByConversationId).forEach(recipientId => {
const recipient = conversationSelector(recipientId);
const recipientSendState = sendStateByConversationId[recipient.id];
if (recipientSendState.status === SendStatus.Viewed) {
views += 1;
}
sendState.push({
...recipientSendState,
recipient: pick(recipient, [
'acceptedMessageRequest',
'avatarPath',
'color',
'id',
'isMe',
'name',
'profileName',
'sharedGroupNames',
'title',
]),
});
});
const existingMyStory = myStoriesById.get(list.id) || { stories: [] };
myStoriesById.set(list.id, {
distributionId: list.id,
distributionName: list.name,
stories: [
...existingMyStory.stories,
{
...storyView,
sendState,
views,
},
],
});
return;
}
2022-04-15 00:08:46 +00:00
const conversationStory = getConversationStory(
conversationSelector,
2022-07-06 19:06:20 +00:00
story
2022-03-04 21:14:52 +00:00
);
let storiesMap: Map<string, ConversationStoryType>;
2022-07-01 00:52:03 +00:00
2022-04-15 00:08:46 +00:00
if (conversationStory.isHidden) {
2022-03-04 21:14:52 +00:00
storiesMap = hiddenStoriesById;
} else {
storiesMap = storiesById;
}
2022-04-15 00:08:46 +00:00
const existingConversationStory = storiesMap.get(
conversationStory.conversationId
2022-07-06 19:06:20 +00:00
);
2022-03-04 21:14:52 +00:00
2022-04-15 00:08:46 +00:00
storiesMap.set(conversationStory.conversationId, {
...existingConversationStory,
2022-03-04 21:14:52 +00:00
...conversationStory,
2022-07-06 19:06:20 +00:00
storyView: conversationStory.storyView,
2022-03-04 21:14:52 +00:00
});
});
return {
2022-07-06 19:06:20 +00:00
hiddenStories: Array.from(hiddenStoriesById.values()),
myStories: Array.from(myStoriesById.values()),
2022-04-08 15:40:15 +00:00
stories: Array.from(storiesById.values()).sort(sortByRecencyAndUnread),
2022-03-04 21:14:52 +00:00
};
}
);