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

563 lines
15 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';
import type { AttachmentType } from '../../types/Attachment';
2022-03-04 21:14:52 +00:00
import type {
ConversationStoryType,
2022-07-01 00:52:03 +00:00
MyStoryType,
ReplyStateType,
StoryDistributionListWithMembersDataType,
2022-07-01 00:52:03 +00:00
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,
AddStoryData,
2022-07-06 19:06:20 +00:00
} from '../ducks/stories';
import { MY_STORY_ID, ResolvedSendStatus } 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,
2022-09-22 00:55:23 +00:00
getHideStoryConversationIds,
2022-04-15 00:08:46 +00:00
getMe,
} from './conversations';
2022-09-21 19:19:16 +00:00
import { getUserConversationId } from './user';
2022-07-01 00:52:03 +00:00
import { getDistributionListSelector } from './storyDistributionLists';
import { calculateExpirationTimestamp } from '../../util/expirationTimer';
import { getMessageIdForLogging } from '../../util/idForLogging';
2022-10-14 21:47:14 +00:00
import * as log from '../../logging/log';
2022-11-09 21:11:45 +00:00
import { SIGNAL_ACI } from '../../types/SignalConversation';
2022-11-16 22:10:11 +00:00
import {
reduceStorySendStatus,
resolveStorySendStatus,
} from '../../util/resolveStorySendStatus';
import { BodyRange, hydrateRanges } from '../../types/BodyRange';
import { getStoriesEnabled } from './items';
2022-03-04 21:14:52 +00:00
export const getStoriesState = (state: StateType): StoriesStateType =>
state.stories;
export const hasSelectedStoryData = createSelector(
getStoriesState,
({ selectedStoryData }): boolean => Boolean(selectedStoryData)
);
2022-07-06 19:06:20 +00:00
export const getSelectedStoryData = createSelector(
getStoriesState,
({ selectedStoryData }): SelectedStoryDataType | undefined =>
selectedStoryData
);
export const getAddStoryData = createSelector(
getStoriesState,
({ addStoryData }): AddStoryData => addStoryData
);
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-11-09 02:38:19 +00:00
if (
2023-08-16 20:54:39 +00:00
storyA.storyView.sender.serviceId === SIGNAL_ACI &&
2022-11-09 02:38:19 +00:00
storyA.storyView.isUnread
) {
return -1;
}
if (
2023-08-16 20:54:39 +00:00
storyB.storyView.sender.serviceId === SIGNAL_ACI &&
2022-11-09 02:38:19 +00:00
storyB.storyView.isUnread
) {
return 1;
}
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-09-21 23:54:48 +00:00
if (storyA.storyView.readAt && storyB.storyView.readAt) {
return storyA.storyView.readAt > storyB.storyView.readAt ? -1 : 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
}
2022-08-24 21:39:10 +00:00
function sortMyStories(storyA: MyStoryType, storyB: MyStoryType): number {
if (storyA.id === MY_STORY_ID) {
2022-08-24 21:39:10 +00:00
return -1;
}
if (storyB.id === MY_STORY_ID) {
2022-08-24 21:39:10 +00:00
return 1;
}
if (!storyA.stories.length) {
return 1;
}
if (!storyB.stories.length) {
return -1;
}
return storyA.stories[0].timestamp > storyB.stories[0].timestamp ? -1 : 1;
}
2022-04-28 22:06:28 +00:00
function getAvatarData(
conversation: ConversationType
): Pick<
ConversationType,
| 'acceptedMessageRequest'
2024-07-11 19:44:09 +00:00
| 'avatarUrl'
| 'badges'
2022-04-28 22:06:28 +00:00
| 'color'
| 'isMe'
| 'id'
2022-04-28 22:06:28 +00:00
| 'name'
| 'profileName'
| 'sharedGroupNames'
| 'title'
> {
return pick(conversation, [
'acceptedMessageRequest',
2024-07-11 19:44:09 +00:00
'avatarUrl',
'badges',
2022-04-28 22:06:28 +00:00
'color',
'isMe',
'id',
2022-04-28 22:06:28 +00:00
'name',
'profileName',
'sharedGroupNames',
'title',
]);
}
export function getStoryDownloadableAttachment({
attachment,
}: StoryDataType): AttachmentType | undefined {
// See: getStoryDataFromMessageAttributes for how preview gets populated.
return attachment?.textAttachment?.preview?.image ?? attachment;
}
2022-07-06 19:06:20 +00:00
export function getStoryView(
2022-04-15 00:08:46 +00:00
conversationSelector: GetConversationByIdType,
2022-09-21 19:19:16 +00:00
ourConversationId: string | undefined,
2022-07-06 19:06:20 +00:00
story: StoryDataType
2022-07-01 00:52:03 +00:00
): StoryViewType {
2023-08-16 20:54:39 +00:00
const sender = pick(
conversationSelector(story.sourceServiceId || story.source),
[
'acceptedMessageRequest',
2024-07-11 19:44:09 +00:00
'avatarUrl',
2023-08-16 20:54:39 +00:00
'badges',
'color',
'firstName',
'hideStory',
'id',
'isMe',
'name',
'profileName',
'sharedGroupNames',
'title',
'serviceId',
]
);
2022-04-15 00:08:46 +00:00
2022-09-21 23:54:48 +00:00
const {
attachment,
bodyRanges,
2022-09-21 23:54:48 +00:00
expirationStartTimestamp,
expireTimer,
readAt,
timestamp,
} = story;
2022-07-01 00:52:03 +00:00
2022-07-07 21:59:08 +00:00
const { sendStateByConversationId } = story;
let sendState: Array<StorySendStateType> | undefined;
let views: number | undefined;
if (sendStateByConversationId) {
const innerSendState: Array<StorySendStateType> = [];
let innerViews = 0;
Object.keys(sendStateByConversationId).forEach(recipientId => {
const recipient = conversationSelector(recipientId);
const recipientSendState = sendStateByConversationId[recipient.id];
if (recipientSendState.status === SendStatus.Viewed) {
innerViews += 1;
}
innerSendState.push({
...recipientSendState,
2022-07-25 18:55:44 +00:00
recipient,
2022-07-07 21:59:08 +00:00
});
});
sendState = innerSendState;
views = innerViews;
}
const messageIdForLogging = getMessageIdForLogging({
2023-08-16 20:54:39 +00:00
...pick(story, 'type', 'sourceServiceId', 'sourceDevice'),
sent_at: story.timestamp,
});
2022-07-01 00:52:03 +00:00
return {
attachment,
bodyRanges: bodyRanges?.filter(BodyRange.isFormatting),
2022-09-21 19:19:16 +00:00
canReply: canReply(story, ourConversationId, conversationSelector),
isHidden: Boolean(sender.hideStory),
2022-07-01 00:52:03 +00:00
isUnread: story.readStatus === ReadStatus.Unread,
messageId: story.messageId,
messageIdForLogging,
2022-09-21 23:54:48 +00:00
readAt,
2022-07-01 00:52:03 +00:00
sender,
2022-07-07 21:59:08 +00:00
sendState,
2022-07-01 00:52:03 +00:00
timestamp,
expirationTimestamp: calculateExpirationTimestamp({
expireTimer,
expirationStartTimestamp,
}),
2022-07-07 21:59:08 +00:00
views,
2022-07-01 00:52:03 +00:00
};
}
2022-07-06 19:06:20 +00:00
export function getConversationStory(
2022-07-01 00:52:03 +00:00
conversationSelector: GetConversationByIdType,
2022-09-21 19:19:16 +00:00
ourConversationId: string | undefined,
2022-07-06 19:06:20 +00:00
story: StoryDataType
2022-07-01 00:52:03 +00:00
): ConversationStoryType {
2023-08-16 20:54:39 +00:00
const sender = pick(
conversationSelector(story.sourceServiceId || story.source),
['id']
);
2022-07-01 00:52:03 +00:00
2022-04-15 00:08:46 +00:00
const conversation = pick(conversationSelector(story.conversationId), [
'acceptedMessageRequest',
2024-07-11 19:44:09 +00:00
'avatarUrl',
2022-04-15 00:08:46 +00:00
'color',
2022-10-26 22:34:57 +00:00
'hideStory',
2022-04-15 00:08:46 +00:00
'id',
'name',
'profileName',
'sharedGroupNames',
2022-08-23 18:02:51 +00:00
'sortedGroupMembers',
2022-04-15 00:08:46 +00:00
'title',
'left',
2022-04-15 00:08:46 +00:00
]);
2022-09-21 19:19:16 +00:00
const storyView = getStoryView(
conversationSelector,
ourConversationId,
story
);
2022-04-15 00:08:46 +00:00
return {
conversationId: conversation.id,
group: conversation.id !== sender.id ? conversation : undefined,
hasReplies: story.hasReplies,
hasRepliesFromSelf: story.hasRepliesFromSelf,
2022-10-26 22:34:57 +00:00
isHidden: Boolean(conversation.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-11-02 23:48:38 +00:00
{ 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 replies = replyState.replies.map(reply => {
const conversation =
reply.type === 'outgoing'
? me
2023-08-16 20:54:39 +00:00
: conversationSelector(reply.sourceServiceId || reply.source);
2022-04-28 22:06:28 +00:00
return {
author: getAvatarData(conversation),
2022-11-10 04:59:36 +00:00
...pick(reply, ['body', 'deletedForEveryone', 'id', 'timestamp']),
bodyRanges: hydrateRanges(reply.bodyRanges, conversationSelector),
2022-11-02 23:48:38 +00:00
reactionEmoji: reply.storyReaction?.emoji,
2022-04-28 22:06:28 +00:00
contactNameColor: contactNameColorSelector(
reply.conversationId,
conversation.id
),
conversationId: conversation.id,
readStatus: reply.readStatus,
2022-04-28 22:06:28 +00:00
};
});
2022-04-15 00:08:46 +00:00
return {
messageId: replyState.messageId,
2022-11-02 23:48:38 +00:00
replies,
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-09-21 19:19:16 +00:00
getUserConversationId,
2022-03-04 21:14:52 +00:00
(
conversationSelector,
2022-07-01 00:52:03 +00:00
distributionListSelector,
2022-09-21 19:19:16 +00:00
{ stories }: Readonly<StoriesStateType>,
ourConversationId
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>;
} => {
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;
}
2023-08-16 20:54:39 +00:00
const isSignalStory = story.sourceServiceId === SIGNAL_ACI;
2022-11-17 17:35:27 +00:00
2022-11-09 02:38:19 +00:00
// if for some reason this story is already expired (bug)
// log it and skip it. Unless it's the onboarding story, that story
// doesn't have an expiration until it is viewed.
if (
2022-11-17 17:35:27 +00:00
!isSignalStory &&
2022-11-09 02:38:19 +00:00
(calculateExpirationTimestamp(story) ?? 0) < Date.now()
) {
2022-10-14 21:47:14 +00:00
const messageIdForLogging = getMessageIdForLogging({
2023-08-16 20:54:39 +00:00
...pick(story, 'type', 'sourceServiceId', 'sourceDevice'),
2022-10-14 21:47:14 +00:00
sent_at: story.timestamp,
});
log.warn('selectors/getStories: story already expired', {
message: messageIdForLogging,
expireTimer: story.expireTimer,
expirationStartTimestamp: story.expirationStartTimestamp,
});
return;
}
2022-08-05 01:13:26 +00:00
const conversationStory = getConversationStory(
conversationSelector,
2022-09-21 19:19:16 +00:00
ourConversationId,
2022-08-05 01:13:26 +00:00
story
);
if (story.sendStateByConversationId) {
let sentId = story.conversationId;
let sentName = conversationStory.group?.title;
if (story.storyDistributionListId) {
const list =
story.storyDistributionListId === MY_STORY_ID
? { id: MY_STORY_ID, name: MY_STORY_ID }
2022-08-05 01:13:26 +00:00
: distributionListSelector(
story.storyDistributionListId.toLowerCase()
);
if (!list) {
return;
}
sentId = list.id;
sentName = list.name;
}
if (!sentName) {
2022-07-01 00:52:03 +00:00
return;
}
2022-09-21 19:19:16 +00:00
const storyView = getStoryView(
conversationSelector,
ourConversationId,
story
);
2022-07-01 00:52:03 +00:00
2022-11-16 22:10:11 +00:00
const existingStorySent = myStoriesById.get(sentId) || {
// Default to "Sent" since it's the lowest form of SendStatus and all
// others take precedence over it.
reducedSendStatus: ResolvedSendStatus.Sent,
stories: [],
};
const sendStatus = resolveStorySendStatus(storyView.sendState ?? []);
2022-07-01 00:52:03 +00:00
2022-08-05 01:13:26 +00:00
myStoriesById.set(sentId, {
id: sentId,
name: sentName,
2022-11-16 22:10:11 +00:00
reducedSendStatus: reduceStorySendStatus(
existingStorySent.reducedSendStatus,
sendStatus
),
stories: [storyView, ...existingStorySent.stories],
2022-07-01 00:52:03 +00:00
});
2022-08-25 16:10:56 +00:00
// If it's a group story we still want it to render as part of regular
// stories or hidden stories.
if (story.storyDistributionListId) {
return;
}
2022-07-01 00:52:03 +00:00
}
2022-03-04 21:14:52 +00:00
let storiesMap: Map<string, ConversationStoryType>;
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-11-17 17:35:27 +00:00
const existingConversationStory =
storiesMap.get(conversationStory.conversationId) || conversationStory;
2022-03-04 21:14:52 +00:00
2022-11-17 17:35:27 +00:00
const conversationStoryObject = {
2022-04-15 00:08:46 +00:00
...existingConversationStory,
hasReplies:
existingConversationStory?.hasReplies || conversationStory.hasReplies,
hasRepliesFromSelf:
existingConversationStory?.hasRepliesFromSelf ||
conversationStory.hasRepliesFromSelf,
2022-11-17 17:35:27 +00:00
};
// For the signal story we want the thumbnail to be from the first story
if (!isSignalStory) {
conversationStoryObject.storyView = conversationStory.storyView;
}
storiesMap.set(conversationStory.conversationId, conversationStoryObject);
2022-03-04 21:14:52 +00:00
});
return {
2022-09-22 18:56:39 +00:00
hiddenStories: Array.from(hiddenStoriesById.values()).sort(
sortByRecencyAndUnread
),
2022-08-24 21:39:10 +00:00
myStories: Array.from(myStoriesById.values()).sort(sortMyStories),
2022-04-08 15:40:15 +00:00
stories: Array.from(storiesById.values()).sort(sortByRecencyAndUnread),
2022-03-04 21:14:52 +00:00
};
}
);
2022-07-20 23:06:15 +00:00
export const getStoriesNotificationCount = createSelector(
getStoriesEnabled,
2022-09-22 00:55:23 +00:00
getHideStoryConversationIds,
2022-07-20 23:06:15 +00:00
getStoriesState,
(
storiesEnabled,
hideStoryConversationIds,
{ lastOpenedAtTimestamp, stories }
): number => {
if (!storiesEnabled) {
return 0;
}
2022-09-22 00:55:23 +00:00
const hiddenConversationIds = new Set(hideStoryConversationIds);
2022-07-22 01:38:27 +00:00
return new Set(
stories
.filter(
story =>
story.readStatus === ReadStatus.Unread &&
!story.deletedForEveryone &&
2022-09-22 00:55:23 +00:00
story.timestamp > (lastOpenedAtTimestamp || 0) &&
!hiddenConversationIds.has(story.conversationId)
)
2022-07-22 01:38:27 +00:00
.map(story => story.conversationId)
).size;
2022-07-20 23:06:15 +00:00
}
);
2022-07-22 00:44:35 +00:00
export const getStoryByIdSelector = createSelector(
getStoriesState,
2022-09-21 19:19:16 +00:00
getUserConversationId,
getDistributionListSelector,
({ stories }, ourConversationId, distributionListSelector) =>
(
conversationSelector: GetConversationByIdType,
messageId: string
):
| {
conversationStory: ConversationStoryType;
distributionList:
| Pick<StoryDistributionListWithMembersDataType, 'id' | 'name'>
| undefined;
storyView: StoryViewType;
}
| undefined => {
const story = stories.find(item => item.messageId === messageId);
if (!story) {
return;
}
let distributionList:
| Pick<StoryDistributionListWithMembersDataType, 'id' | 'name'>
| undefined;
if (story.storyDistributionListId) {
distributionList =
story.storyDistributionListId === MY_STORY_ID
? { id: MY_STORY_ID, name: MY_STORY_ID }
: distributionListSelector(
story.storyDistributionListId.toLowerCase()
);
}
return {
2022-09-21 19:19:16 +00:00
conversationStory: getConversationStory(
conversationSelector,
ourConversationId,
story
),
distributionList,
2022-09-21 19:19:16 +00:00
storyView: getStoryView(conversationSelector, ourConversationId, story),
};
}
);
export const getHasAllStoriesUnmuted = createSelector(
getStoriesState,
({ hasAllStoriesUnmuted }): boolean => hasAllStoriesUnmuted
);
2023-02-07 19:33:04 +00:00
export const getHasAnyFailedStorySends = createSelector(
getStoriesState,
({ lastOpenedAtTimestamp, stories }): boolean => {
return stories.some(
story =>
story.timestamp > (lastOpenedAtTimestamp || 0) &&
Object.values(story.sendStateByConversationId || {}).some(
({ status }) => status === SendStatus.Failed
)
);
}
);