// Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { createSelector } from 'reselect'; import { pick } from 'lodash'; import type { GetConversationByIdType } from './conversations'; import type { ConversationType } from '../ducks/conversations'; import type { AttachmentType } from '../../types/Attachment'; import type { ConversationStoryType, MyStoryType, ReplyStateType, StoryDistributionListWithMembersDataType, StorySendStateType, StoryViewType, } from '../../types/Stories'; import type { StateType } from '../reducer'; import type { SelectedStoryDataType, StoryDataType, StoriesStateType, AddStoryData, } from '../ducks/stories'; import { MY_STORY_ID, ResolvedSendStatus } from '../../types/Stories'; import { ReadStatus } from '../../messages/MessageReadStatus'; import { SendStatus } from '../../messages/MessageSendState'; import { canReply } from './message'; import { getContactNameColorSelector, getConversationSelector, getHideStoryConversationIds, getMe, } from './conversations'; import { getUserConversationId } from './user'; import { getDistributionListSelector } from './storyDistributionLists'; import { calculateExpirationTimestamp } from '../../util/expirationTimer'; import { getMessageIdForLogging } from '../../util/idForLogging'; import * as log from '../../logging/log'; import { SIGNAL_ACI } from '../../types/SignalConversation'; import { reduceStorySendStatus, resolveStorySendStatus, } from '../../util/resolveStorySendStatus'; import { BodyRange, hydrateRanges } from '../../types/BodyRange'; export const getStoriesState = (state: StateType): StoriesStateType => state.stories; export const hasSelectedStoryData = createSelector( getStoriesState, ({ selectedStoryData }): boolean => Boolean(selectedStoryData) ); export const getSelectedStoryData = createSelector( getStoriesState, ({ selectedStoryData }): SelectedStoryDataType | undefined => selectedStoryData ); export const getAddStoryData = createSelector( getStoriesState, ({ addStoryData }): AddStoryData => addStoryData ); function sortByRecencyAndUnread( storyA: ConversationStoryType, storyB: ConversationStoryType ): number { if ( storyA.storyView.sender.uuid === SIGNAL_ACI && storyA.storyView.isUnread ) { return -1; } if ( storyB.storyView.sender.uuid === SIGNAL_ACI && storyB.storyView.isUnread ) { return 1; } if (storyA.storyView.isUnread && storyB.storyView.isUnread) { return storyA.storyView.timestamp > storyB.storyView.timestamp ? -1 : 1; } if (storyB.storyView.isUnread) { return 1; } if (storyA.storyView.isUnread) { return -1; } if (storyA.storyView.readAt && storyB.storyView.readAt) { return storyA.storyView.readAt > storyB.storyView.readAt ? -1 : 1; } return storyA.storyView.timestamp > storyB.storyView.timestamp ? -1 : 1; } function sortMyStories(storyA: MyStoryType, storyB: MyStoryType): number { if (storyA.id === MY_STORY_ID) { return -1; } if (storyB.id === MY_STORY_ID) { 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; } function getAvatarData( conversation: ConversationType ): Pick< ConversationType, | 'acceptedMessageRequest' | 'avatarPath' | 'badges' | 'color' | 'isMe' | 'id' | 'name' | 'profileName' | 'sharedGroupNames' | 'title' > { return pick(conversation, [ 'acceptedMessageRequest', 'avatarPath', 'badges', 'color', 'isMe', 'id', 'name', 'profileName', 'sharedGroupNames', 'title', ]); } export function getStoryDownloadableAttachment({ attachment, }: StoryDataType): AttachmentType | undefined { // See: getStoryDataFromMessageAttributes for how preview gets populated. return attachment?.textAttachment?.preview?.image ?? attachment; } export function getStoryView( conversationSelector: GetConversationByIdType, ourConversationId: string | undefined, story: StoryDataType ): StoryViewType { const sender = pick(conversationSelector(story.sourceUuid || story.source), [ 'acceptedMessageRequest', 'avatarPath', 'badges', 'color', 'firstName', 'hideStory', 'id', 'isMe', 'name', 'profileName', 'sharedGroupNames', 'title', 'uuid', ]); const { attachment, bodyRanges, expirationStartTimestamp, expireTimer, readAt, timestamp, } = story; const { sendStateByConversationId } = story; let sendState: Array | undefined; let views: number | undefined; if (sendStateByConversationId) { const innerSendState: Array = []; 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, recipient, }); }); sendState = innerSendState; views = innerViews; } const messageIdForLogging = getMessageIdForLogging({ ...pick(story, 'type', 'sourceUuid', 'sourceDevice'), sent_at: story.timestamp, }); return { attachment, bodyRanges: bodyRanges?.filter(BodyRange.isFormatting), canReply: canReply(story, ourConversationId, conversationSelector), isHidden: Boolean(sender.hideStory), isUnread: story.readStatus === ReadStatus.Unread, messageId: story.messageId, messageIdForLogging, readAt, sender, sendState, timestamp, expirationTimestamp: calculateExpirationTimestamp({ expireTimer, expirationStartTimestamp, }), views, }; } export function getConversationStory( conversationSelector: GetConversationByIdType, ourConversationId: string | undefined, story: StoryDataType ): ConversationStoryType { const sender = pick(conversationSelector(story.sourceUuid || story.source), [ 'id', ]); const conversation = pick(conversationSelector(story.conversationId), [ 'acceptedMessageRequest', 'avatarPath', 'color', 'hideStory', 'id', 'name', 'profileName', 'sharedGroupNames', 'sortedGroupMembers', 'title', 'left', ]); const storyView = getStoryView( conversationSelector, ourConversationId, story ); return { conversationId: conversation.id, group: conversation.id !== sender.id ? conversation : undefined, hasReplies: story.hasReplies, hasRepliesFromSelf: story.hasRepliesFromSelf, isHidden: Boolean(conversation.hideStory), storyView, }; } export const getStoryReplies = createSelector( getConversationSelector, getContactNameColorSelector, getMe, getStoriesState, ( conversationSelector, contactNameColorSelector, me, { replyState }: Readonly ): ReplyStateType | undefined => { if (!replyState) { return; } const replies = replyState.replies.map(reply => { const conversation = reply.type === 'outgoing' ? me : conversationSelector(reply.sourceUuid || reply.source); return { author: getAvatarData(conversation), ...pick(reply, ['body', 'deletedForEveryone', 'id', 'timestamp']), bodyRanges: hydrateRanges(reply.bodyRanges, conversationSelector), reactionEmoji: reply.storyReaction?.emoji, contactNameColor: contactNameColorSelector( reply.conversationId, conversation.id ), conversationId: conversation.id, readStatus: reply.readStatus, }; }); return { messageId: replyState.messageId, replies, }; } ); export const getStories = createSelector( getConversationSelector, getDistributionListSelector, getStoriesState, getUserConversationId, ( conversationSelector, distributionListSelector, { stories }: Readonly, ourConversationId ): { hiddenStories: Array; myStories: Array; stories: Array; } => { const hiddenStoriesById = new Map(); const myStoriesById = new Map(); const storiesById = new Map(); stories.forEach(story => { if (story.deletedForEveryone) { return; } const isSignalStory = story.sourceUuid === SIGNAL_ACI; // 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 ( !isSignalStory && (calculateExpirationTimestamp(story) ?? 0) < Date.now() ) { const messageIdForLogging = getMessageIdForLogging({ ...pick(story, 'type', 'sourceUuid', 'sourceDevice'), sent_at: story.timestamp, }); log.warn('selectors/getStories: story already expired', { message: messageIdForLogging, expireTimer: story.expireTimer, expirationStartTimestamp: story.expirationStartTimestamp, }); return; } const conversationStory = getConversationStory( conversationSelector, ourConversationId, 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 } : distributionListSelector( story.storyDistributionListId.toLowerCase() ); if (!list) { return; } sentId = list.id; sentName = list.name; } if (!sentName) { return; } const storyView = getStoryView( conversationSelector, ourConversationId, story ); 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 ?? []); myStoriesById.set(sentId, { id: sentId, name: sentName, reducedSendStatus: reduceStorySendStatus( existingStorySent.reducedSendStatus, sendStatus ), stories: [storyView, ...existingStorySent.stories], }); // If it's a group story we still want it to render as part of regular // stories or hidden stories. if (story.storyDistributionListId) { return; } } let storiesMap: Map; if (conversationStory.isHidden) { storiesMap = hiddenStoriesById; } else { storiesMap = storiesById; } const existingConversationStory = storiesMap.get(conversationStory.conversationId) || conversationStory; const conversationStoryObject = { ...existingConversationStory, hasReplies: existingConversationStory?.hasReplies || conversationStory.hasReplies, hasRepliesFromSelf: existingConversationStory?.hasRepliesFromSelf || conversationStory.hasRepliesFromSelf, }; // 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); }); return { hiddenStories: Array.from(hiddenStoriesById.values()).sort( sortByRecencyAndUnread ), myStories: Array.from(myStoriesById.values()).sort(sortMyStories), stories: Array.from(storiesById.values()).sort(sortByRecencyAndUnread), }; } ); export const getStoriesNotificationCount = createSelector( getHideStoryConversationIds, getStoriesState, (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) && !hiddenConversationIds.has(story.conversationId) ) .map(story => story.conversationId) ).size; } ); export const getStoryByIdSelector = createSelector( getStoriesState, getUserConversationId, getDistributionListSelector, ({ stories }, ourConversationId, distributionListSelector) => ( conversationSelector: GetConversationByIdType, messageId: string ): | { conversationStory: ConversationStoryType; distributionList: | Pick | undefined; storyView: StoryViewType; } | undefined => { const story = stories.find(item => item.messageId === messageId); if (!story) { return; } let distributionList: | Pick | undefined; if (story.storyDistributionListId) { distributionList = story.storyDistributionListId === MY_STORY_ID ? { id: MY_STORY_ID, name: MY_STORY_ID } : distributionListSelector( story.storyDistributionListId.toLowerCase() ); } return { conversationStory: getConversationStory( conversationSelector, ourConversationId, story ), distributionList, storyView: getStoryView(conversationSelector, ourConversationId, story), }; } ); export const getHasAllStoriesUnmuted = createSelector( getStoriesState, ({ hasAllStoriesUnmuted }): boolean => hasAllStoriesUnmuted ); 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 ) ); } );