diff --git a/ts/components/MessageSearchResult.tsx b/ts/components/MessageSearchResult.tsx index 7a7111cc4..8370e0bce 100644 --- a/ts/components/MessageSearchResult.tsx +++ b/ts/components/MessageSearchResult.tsx @@ -7,31 +7,7 @@ import { Timestamp } from './conversation/Timestamp'; import { ContactName } from './conversation/ContactName'; import { DefaultTheme, withTheme } from 'styled-components'; - -export type MessageSearchResultProps = { - id: string; - conversationId: string; - receivedAt: number; - - snippet: string; - - from: { - phoneNumber: string; - isMe?: boolean; - name?: string; - color?: string; - profileName?: string; - avatarPath?: string; - }; - - to: { - groupName?: string; - phoneNumber: string; - isMe?: boolean; - name?: string; - profileName?: string; - }; -}; +import { PropsForSearchResults } from '../state/ducks/conversations'; type PropsHousekeeping = { isSelected?: boolean; @@ -39,7 +15,7 @@ type PropsHousekeeping = { onClick: (conversationId: string, messageId?: string) => void; }; -type Props = MessageSearchResultProps & PropsHousekeeping; +type Props = PropsForSearchResults & PropsHousekeeping; class MessageSearchResultInner extends React.PureComponent { public renderFromName() { @@ -59,10 +35,11 @@ class MessageSearchResultInner extends React.PureComponent { } return ( + // tslint:disable: use-simple-attributes @@ -80,8 +57,8 @@ class MessageSearchResultInner extends React.PureComponent { @@ -98,7 +75,7 @@ class MessageSearchResultInner extends React.PureComponent { return ( {
- +
diff --git a/ts/components/SearchResults.tsx b/ts/components/SearchResults.tsx index 3c4fa0162..79a571ca6 100644 --- a/ts/components/SearchResults.tsx +++ b/ts/components/SearchResults.tsx @@ -1,12 +1,13 @@ import React from 'react'; +import { PropsForSearchResults } from '../state/ducks/conversations'; import { ConversationListItemProps, ConversationListItemWithDetails } from './ConversationListItem'; -import { MessageSearchResult, MessageSearchResultProps } from './MessageSearchResult'; +import { MessageSearchResult } from './MessageSearchResult'; export type SearchResultsProps = { contacts: Array; conversations: Array; hideMessagesHeader: boolean; - messages: Array; + messages: Array; searchTerm: string; }; diff --git a/ts/components/conversation/DataExtractionNotification.tsx b/ts/components/conversation/DataExtractionNotification.tsx index f9ce12561..7c23e2919 100644 --- a/ts/components/conversation/DataExtractionNotification.tsx +++ b/ts/components/conversation/DataExtractionNotification.tsx @@ -1,14 +1,12 @@ import React from 'react'; import { useTheme } from 'styled-components'; -import { DataExtractionNotificationProps } from '../../models/messageType'; +import { PropsForDataExtractionNotification } from '../../models/messageType'; import { SignalService } from '../../protobuf'; import { Flex } from '../basic/Flex'; import { SessionIcon, SessionIconSize, SessionIconType } from '../session/icon'; import { SpacerXS, Text } from '../basic/Text'; -type Props = DataExtractionNotificationProps; - -export const DataExtractionNotification = (props: Props) => { +export const DataExtractionNotification = (props: PropsForDataExtractionNotification) => { const theme = useTheme(); const { name, type, source } = props; diff --git a/ts/components/conversation/GroupInvitation.tsx b/ts/components/conversation/GroupInvitation.tsx index 9ba71ee7b..b8ca56556 100644 --- a/ts/components/conversation/GroupInvitation.tsx +++ b/ts/components/conversation/GroupInvitation.tsx @@ -2,15 +2,10 @@ import React from 'react'; import classNames from 'classnames'; import { SessionIconButton, SessionIconSize, SessionIconType } from '../session/icon'; import { useTheme } from 'styled-components'; +import { PropsForGroupInvitation } from '../../state/ducks/conversations'; +import { acceptOpenGroupInvitation } from '../../interactions/messageInteractions'; -type Props = { - name: string; - url: string; - direction: string; - onJoinClick: () => void; -}; - -export const GroupInvitation = (props: Props) => { +export const GroupInvitation = (props: PropsForGroupInvitation) => { const theme = useTheme(); const classes = ['group-invitation']; @@ -28,10 +23,12 @@ export const GroupInvitation = (props: Props) => { iconColor={theme.colors.accent} theme={theme} iconSize={SessionIconSize.Large} - onClick={props.onJoinClick} + onClick={() => { + acceptOpenGroupInvitation(props.acceptUrl, props.serverName); + }} /> - {props.name} + {props.serverName} {openGroupInvitation} {props.url} diff --git a/ts/components/conversation/GroupNotification.tsx b/ts/components/conversation/GroupNotification.tsx index 00d780742..961cb5d75 100644 --- a/ts/components/conversation/GroupNotification.tsx +++ b/ts/components/conversation/GroupNotification.tsx @@ -3,89 +3,92 @@ import { compact, flatten } from 'lodash'; import { Intl } from '../Intl'; import { missingCaseError } from '../../util/missingCaseError'; +import { + PropsForGroupUpdate, + PropsForGroupUpdateAdd, + PropsForGroupUpdateKicked, + PropsForGroupUpdateRemove, + PropsForGroupUpdateType, +} from '../../state/ducks/conversations'; +import _ from 'underscore'; -interface Contact { - phoneNumber: string; - profileName?: string; - name?: string; -} +// This component is used to display group updates in the conversation view. +// This is a not a "notification" as the name suggests, but a message inside the conversation -interface Change { - type: 'add' | 'remove' | 'name' | 'general' | 'kicked'; - isMe: boolean; - newName?: string; - contacts?: Array; +type TypeWithContacts = + | PropsForGroupUpdateAdd + | PropsForGroupUpdateKicked + | PropsForGroupUpdateRemove; + +function isTypeWithContact(change: PropsForGroupUpdateType): change is TypeWithContacts { + return (change as TypeWithContacts).contacts !== undefined; } -type Props = { - changes: Array; -}; +function getPeople(change: TypeWithContacts) { + return _.compact( + flatten( + (change.contacts || []).map((contact, index) => { + const element = ( + + {contact.profileName || contact.phoneNumber} + + ); + + return [index > 0 ? ', ' : null, element]; + }) + ) + ); +} -// This component is used to display group updates in the conversation view. -// This is a not a "notification" as the name suggests, but a message inside the conversation -export const GroupNotification = (props: Props) => { - function renderChange(change: Change) { - const { isMe, contacts, type, newName } = change; - - const people = compact( - flatten( - (contacts || []).map((contact, index) => { - const element = ( - - {contact.profileName || contact.phoneNumber} - - ); - - return [index > 0 ? ', ' : null, element]; - }) - ) - ); - - switch (type) { - case 'name': - return `${window.i18n('titleIsNow', [newName || ''])}.`; - case 'add': - if (!contacts || !contacts.length) { - throw new Error('Group update add is missing contacts'); - } - - const joinKey = contacts.length > 1 ? 'multipleJoinedTheGroup' : 'joinedTheGroup'; - - return ; - case 'remove': - if (isMe) { - return window.i18n('youLeftTheGroup'); - } - - if (!contacts || !contacts.length) { - throw new Error('Group update remove is missing contacts'); - } - - const leftKey = contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup'; - - return ; - case 'kicked': - if (isMe) { - return window.i18n('youGotKickedFromGroup'); - } - - if (!contacts || !contacts.length) { - throw new Error('Group update kicked is missing contacts'); - } - - const kickedKey = contacts.length > 1 ? 'multipleKickedFromTheGroup' : 'kickedFromTheGroup'; - - return ; - case 'general': - return window.i18n('updatedTheGroup'); - default: - throw missingCaseError(type); - } +function renderChange(change: PropsForGroupUpdateType) { + const people = isTypeWithContact(change) ? getPeople(change) : []; + switch (change.type) { + case 'name': + return `${window.i18n('titleIsNow', [change.newName || ''])}.`; + case 'add': + if (!change.contacts || !change.contacts.length) { + throw new Error('Group update add is missing contacts'); + } + + const joinKey = change.contacts.length > 1 ? 'multipleJoinedTheGroup' : 'joinedTheGroup'; + + return ; + case 'remove': + if (change.isMe) { + return window.i18n('youLeftTheGroup'); + } + + if (!change.contacts || !change.contacts.length) { + throw new Error('Group update remove is missing contacts'); + } + + const leftKey = change.contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup'; + + return ; + case 'kicked': + if (change.isMe) { + return window.i18n('youGotKickedFromGroup'); + } + + if (!change.contacts || !change.contacts.length) { + throw new Error('Group update kicked is missing contacts'); + } + + const kickedKey = + change.contacts.length > 1 ? 'multipleKickedFromTheGroup' : 'kickedFromTheGroup'; + + return ; + case 'general': + return window.i18n('updatedTheGroup'); + default: + window.log.error('Missing case error'); } +} +export const GroupNotification = (props: PropsForGroupUpdate) => { const { changes } = props; return (
diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 2fd928ccf..86cd3063e 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -42,6 +42,7 @@ import { MessageInteraction } from '../../interactions'; import autoBind from 'auto-bind'; import { AudioPlayerWithEncryptedFile } from './H5AudioPlayer'; import { ClickToTrustSender } from './message/ClickToTrustSender'; +import { getMessageById } from '../../data/data'; // Same as MIN_WIDTH in ImageGrid.tsx const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200; @@ -526,7 +527,6 @@ class MessageInner extends React.PureComponent { onSelectMessage, onDeleteMessage, onDownload, - onRetrySend, onShowDetail, isPublic, isOpenGroupV2, @@ -582,7 +582,18 @@ class MessageInner extends React.PureComponent { {window.i18n('replyToMessage')} {window.i18n('moreInformation')} - {showRetry ? {window.i18n('resend')} : null} + {showRetry ? ( + { + const found = await getMessageById(id); + if (found) { + await found.retrySend(); + } + }} + > + {window.i18n('resend')} + + ) : null} {isDeletable ? ( <> { // tslint:disable-next-line: cyclomatic-complexity public render() { - const { - direction, - id, - selected, - multiSelectMode, - conversationType, - isPublic, - text, - isUnread, - markRead, - } = this.props; + const { direction, id, selected, multiSelectMode, conversationType, isUnread } = this.props; const { expired, expiring } = this.state; if (expired) { @@ -728,11 +729,12 @@ class MessageInner extends React.PureComponent { divClasses.push('flash-green-once'); } - const onVisible = (inView: boolean) => { + const onVisible = async (inView: boolean) => { if (inView && shouldMarkReadWhenVisible) { + const found = await getMessageById(id); // mark the message as read. // this will trigger the expire timer. - void markRead(Date.now()); + void found?.markRead(Date.now()); } }; diff --git a/ts/components/conversation/TimerNotification.tsx b/ts/components/conversation/TimerNotification.tsx index 9e6c8db16..41fdc4dd6 100644 --- a/ts/components/conversation/TimerNotification.tsx +++ b/ts/components/conversation/TimerNotification.tsx @@ -5,17 +5,9 @@ import { Intl } from '../Intl'; import { missingCaseError } from '../../util/missingCaseError'; import { SessionIcon, SessionIconSize, SessionIconType } from '../session/icon'; import { ThemeContext } from 'styled-components'; +import { PropsForExpirationTimer } from '../../state/ducks/conversations'; -type Props = { - type: 'fromOther' | 'fromMe' | 'fromSync'; - phoneNumber: string; - profileName?: string; - name?: string; - disabled: boolean; - timespan: string; -}; - -export const TimerNotification = (props: Props) => { +export const TimerNotification = (props: PropsForExpirationTimer) => { function renderContents() { const { phoneNumber, profileName, timespan, type, disabled } = props; const changeKey = disabled ? 'disabledDisappearingMessages' : 'theyChangedTheTimer'; diff --git a/ts/components/session/SessionNotificationCount.tsx b/ts/components/session/SessionNotificationCount.tsx index 0e81caa4f..62111b2b8 100644 --- a/ts/components/session/SessionNotificationCount.tsx +++ b/ts/components/session/SessionNotificationCount.tsx @@ -28,11 +28,11 @@ const StyledCountContainer = styled.div<{ shouldRender: boolean }>` /* cursor: */ `; -const StyledCount = styled.div<{ overflow: boolean }>` +const StyledCount = styled.div<{ countOverflow: boolean }>` position: relative; - font-size: ${props => (props.overflow ? '0.5em' : '0.6em')}; - margin-top: ${props => (props.overflow ? '0.35em' : '0em')}; - margin-left: ${props => (props.overflow ? '-0.45em' : '0em')}; + font-size: ${props => (props.countOverflow ? '0.5em' : '0.6em')}; + margin-top: ${props => (props.countOverflow ? '0.35em' : '0em')}; + margin-left: ${props => (props.countOverflow ? '-0.45em' : '0em')}; `; const StyledCountSup = styled.div` @@ -50,7 +50,7 @@ export const SessionNotificationCount = (props: Props) => { if (overflow) { return ( - + {9} + @@ -59,7 +59,7 @@ export const SessionNotificationCount = (props: Props) => { } return ( - {count} + {count} ); }; diff --git a/ts/components/session/conversation/SessionConversation.tsx b/ts/components/session/conversation/SessionConversation.tsx index dddb87f62..c41a0bf51 100644 --- a/ts/components/session/conversation/SessionConversation.tsx +++ b/ts/components/session/conversation/SessionConversation.tsx @@ -715,7 +715,7 @@ export class SessionConversation extends React.Component { } private onClickAttachment(attachment: any, message: any) { - // message is MessageTypeInConvo.propsForMessage I think + // message is MessageModelProps.propsForMessage I think const media = (message.attachments || []).map((attachmentForMedia: any) => { return { objectURL: attachmentForMedia.url, diff --git a/ts/components/session/conversation/SessionMessagesList.tsx b/ts/components/session/conversation/SessionMessagesList.tsx index e3af18792..5df0aeef5 100644 --- a/ts/components/session/conversation/SessionMessagesList.tsx +++ b/ts/components/session/conversation/SessionMessagesList.tsx @@ -10,14 +10,18 @@ import { contextMenu } from 'react-contexify'; import { AttachmentType } from '../../../types/Attachment'; import { GroupNotification } from '../../conversation/GroupNotification'; import { GroupInvitation } from '../../conversation/GroupInvitation'; -import { ConversationType } from '../../../state/ducks/conversations'; +import { + ConversationType, + MessageModelProps, + SortedMessageModelProps, +} from '../../../state/ducks/conversations'; import { SessionLastSeenIndicator } from './SessionLastSeenIndicator'; import { ToastUtils } from '../../../session/utils'; import { TypingBubble } from '../../conversation/TypingBubble'; import { getConversationController } from '../../../session/conversations'; import { MessageModel } from '../../../models/message'; import { MessageRegularProps } from '../../../models/messageType'; -import { getMessagesBySentAt } from '../../../data/data'; +import { getMessageById, getMessagesBySentAt } from '../../../data/data'; import autoBind from 'auto-bind'; import { ConversationTypeEnum } from '../../../models/conversation'; import { DataExtractionNotification } from '../../conversation/DataExtractionNotification'; @@ -25,13 +29,13 @@ import { DataExtractionNotification } from '../../conversation/DataExtractionNot interface State { showScrollButton: boolean; animateQuotedMessageId?: string; - nextMessageToPlay: number | null; + nextMessageToPlay: number | undefined; } interface Props { selectedMessages: Array; conversationKey: string; - messages: Array; + messages: Array; conversation: ConversationType; ourPrimary: string; messageContainerRef: React.RefObject; @@ -52,7 +56,7 @@ interface Props { messageTimestamp, }: { attachment: any; - messageTimestamp: number; + messageTimestamp?: number; messageSender: string; }) => void; onDeleteSelectedMessages: () => Promise; @@ -69,7 +73,7 @@ export class SessionMessagesList extends React.Component { this.state = { showScrollButton: false, - nextMessageToPlay: null, + nextMessageToPlay: undefined, }; autoBind(this); @@ -150,16 +154,21 @@ export class SessionMessagesList extends React.Component { conversationType={conversation.type} displayedName={displayedName} isTyping={conversation.isTyping} + key="typing-bubble" /> {this.renderMessages(messages)} - +
); } - private displayUnreadBannerIndex(messages: Array) { + private displayUnreadBannerIndex(messages: Array) { const { conversation } = this.props; if (conversation.unreadCount === 0) { return -1; @@ -177,10 +186,13 @@ export class SessionMessagesList extends React.Component { // Basically, count the number of incoming messages from the most recent one. for (let index = 0; index <= messages.length - 1; index++) { const message = messages[index]; - if (message.attributes.type === 'incoming') { + if (message.propsForMessage.direction === 'incoming') { incomingMessagesSoFar++; // message.attributes.unread is !== undefined if the message is unread. - if (message.attributes.unread !== undefined && incomingMessagesSoFar >= unreadCount) { + if ( + message.propsForMessage.isUnread !== undefined && + incomingMessagesSoFar >= unreadCount + ) { findFirstUnreadIndex = index; break; } @@ -194,23 +206,22 @@ export class SessionMessagesList extends React.Component { return findFirstUnreadIndex; } - private renderMessages(messages: Array) { - const { conversation, ourPrimary, selectedMessages } = this.props; + private renderMessages(messagesProps: Array) { + const { selectedMessages } = this.props; const multiSelectMode = Boolean(selectedMessages.length); let currentMessageIndex = 0; let playableMessageIndex = 0; - const displayUnreadBannerIndex = this.displayUnreadBannerIndex(messages); + const displayUnreadBannerIndex = this.displayUnreadBannerIndex(messagesProps); return ( <> - {messages.map((message: MessageModel) => { - const messageProps = message.propsForMessage; - - const timerProps = message.propsForTimerNotification; - const propsForGroupInvitation = message.propsForGroupInvitation; - const propsForDataExtractionNotification = message.propsForDataExtractionNotification; + {messagesProps.map((messageProps: SortedMessageModelProps) => { + const timerProps = messageProps.propsForTimerNotification; + const propsForGroupInvitation = messageProps.propsForGroupInvitation; + const propsForDataExtractionNotification = + messageProps.propsForDataExtractionNotification; - const groupNotificationProps = message.propsForGroupNotification; + const groupNotificationProps = messageProps.propsForGroupNotification; // IF there are some unread messages // AND we found the last read message @@ -224,88 +235,71 @@ export class SessionMessagesList extends React.Component { ); - + console.warn('key', messageProps.propsForMessage.id); currentMessageIndex = currentMessageIndex + 1; if (groupNotificationProps) { return ( - <> - + + {unreadIndicator} - + ); } if (propsForGroupInvitation) { return ( - <> - + + {unreadIndicator} - + ); } if (propsForDataExtractionNotification) { return ( - <> + {unreadIndicator} - + ); } if (timerProps) { return ( - <> - + + {unreadIndicator} - + ); } if (!messageProps) { return; } - if (messageProps) { - messageProps.nextMessageToPlay = this.state.nextMessageToPlay; - messageProps.playableMessageIndex = playableMessageIndex; - messageProps.playNextMessage = this.playNextMessage; - } playableMessageIndex++; - if (messageProps.conversationType === ConversationTypeEnum.GROUP) { - messageProps.weAreAdmin = conversation.groupAdmins?.includes(ourPrimary); - } - // a message is deletable if - // either we sent it, - // or the convo is not a public one (in this case, we will only be able to delete for us) - // or the convo is public and we are an admin - const isDeletable = - messageProps.authorPhoneNumber === this.props.ourPrimary || - !conversation.isPublic || - (conversation.isPublic && !!messageProps.weAreAdmin); - - messageProps.isDeletable = isDeletable; - messageProps.isAdmin = conversation.groupAdmins?.includes(messageProps.authorPhoneNumber); - // firstMessageOfSeries tells us to render the avatar only for the first message // in a series of messages from the same user return ( - <> + {this.renderMessage( messageProps, messageProps.firstMessageOfSeries, multiSelectMode, - message + playableMessageIndex )} {unreadIndicator} - + ); })} @@ -313,40 +307,50 @@ export class SessionMessagesList extends React.Component { } private renderMessage( - messageProps: MessageRegularProps, + messageProps: SortedMessageModelProps, firstMessageOfSeries: boolean, multiSelectMode: boolean, - message: MessageModel + playableMessageIndex: number ) { - const selected = !!messageProps?.id && this.props.selectedMessages.includes(messageProps.id); - - messageProps.selected = selected; - messageProps.firstMessageOfSeries = firstMessageOfSeries; - - messageProps.multiSelectMode = multiSelectMode; - messageProps.onSelectMessage = this.props.selectMessage; - messageProps.onDeleteMessage = this.props.deleteMessage; - messageProps.onReply = this.props.replyToMessage; - messageProps.onShowDetail = async () => { - const messageDetailsProps = await message.getPropsForMessageDetail(); - this.props.showMessageDetails(messageDetailsProps); + const regularProps: MessageRegularProps = { ...messageProps.propsForMessage }; + + const messageId = messageProps.propsForMessage.id; + const selected = + !!messageProps?.propsForMessage.id && this.props.selectedMessages.includes(messageId); + + regularProps.selected = selected; + regularProps.firstMessageOfSeries = firstMessageOfSeries; + + regularProps.multiSelectMode = multiSelectMode; + regularProps.onSelectMessage = this.props.selectMessage; + regularProps.onDeleteMessage = this.props.deleteMessage; + regularProps.onReply = this.props.replyToMessage; + regularProps.onShowDetail = async () => { + const found = await getMessageById(messageId); + if (found) { + const messageDetailsProps = await found.getPropsForMessageDetail(); + + this.props.showMessageDetails(messageDetailsProps); + } else { + window.log.warn(`Message ${messageId} not found in db`); + } }; - messageProps.onClickAttachment = (attachment: AttachmentType) => { + regularProps.onClickAttachment = (attachment: AttachmentType) => { this.props.onClickAttachment(attachment, messageProps); }; - messageProps.onDownload = (attachment: AttachmentType) => { + regularProps.onDownload = (attachment: AttachmentType) => { this.props.onDownloadAttachment({ attachment, - messageTimestamp: messageProps.timestamp, - messageSender: messageProps.authorPhoneNumber, + messageTimestamp: messageProps.propsForMessage.timestamp, + messageSender: messageProps.propsForMessage.authorPhoneNumber, }); }; - messageProps.isQuotedMessageToAnimate = messageProps.id === this.state.animateQuotedMessageId; + regularProps.isQuotedMessageToAnimate = messageId === this.state.animateQuotedMessageId; - if (messageProps.quote) { - messageProps.quote.onClick = (options: { + if (regularProps.quote) { + regularProps.quote.onClick = (options: { quoteAuthor: string; quoteId: any; referencedMessageNotFound: boolean; @@ -355,7 +359,11 @@ export class SessionMessagesList extends React.Component { }; } - return ; + regularProps.nextMessageToPlay = this.state.nextMessageToPlay; + regularProps.playableMessageIndex = playableMessageIndex; + regularProps.playNextMessage = this.playNextMessage; + + return ; } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -379,7 +387,7 @@ export class SessionMessagesList extends React.Component { } if (this.getScrollOffsetBottomPx() === 0) { - void conversation.markRead(messages[0].attributes.received_at); + void conversation.markRead(messages[0].propsForMessage.receivedAt); } } @@ -389,12 +397,12 @@ export class SessionMessagesList extends React.Component { */ private readonly playNextMessage = (index: any) => { const { messages } = this.props; - let nextIndex: number | null = index - 1; + let nextIndex: number | undefined = index - 1; // to prevent autoplaying as soon as a message is received. const latestMessagePlayed = index <= 0 || messages.length < index - 1; if (latestMessagePlayed) { - nextIndex = null; + nextIndex = undefined; this.setState({ nextMessageToPlay: nextIndex, }); @@ -406,7 +414,7 @@ export class SessionMessagesList extends React.Component { const nextAuthorNumber = messages[index - 1].propsForMessage.authorPhoneNumber; const differentAuthor = prevAuthorNumber !== nextAuthorNumber; if (differentAuthor) { - nextIndex = null; + nextIndex = undefined; } this.setState({ @@ -463,7 +471,7 @@ export class SessionMessagesList extends React.Component { const numMessages = this.props.messages.length + Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT; const oldLen = messages.length; - const previousTopMessage = messages[oldLen - 1]?.id; + const previousTopMessage = messages[oldLen - 1]?.propsForMessage.id; fetchMessagesForConversation({ conversationKey, count: numMessages }); if (previousTopMessage && oldLen !== messages.length) { @@ -484,7 +492,7 @@ export class SessionMessagesList extends React.Component { } if (message) { - this.scrollToMessage(message.id); + this.scrollToMessage(message.propsForMessage.id); } } @@ -584,9 +592,9 @@ export class SessionMessagesList extends React.Component { const collection = await getMessagesBySentAt(quoteId); const found = Boolean( collection.find((item: MessageModel) => { - const messageAuthor = item.propsForMessage?.authorPhoneNumber; + const messageAuthor = item.getSource(); - return messageAuthor && quoteAuthor === messageAuthor; + return Boolean(messageAuthor && quoteAuthor === messageAuthor); }) ); @@ -598,7 +606,7 @@ export class SessionMessagesList extends React.Component { return; } - const databaseId = targetMessage.id; + const databaseId = targetMessage.propsForMessage.id; this.scrollToMessage(databaseId, true); } diff --git a/ts/components/session/menu/Menu.tsx b/ts/components/session/menu/Menu.tsx index e7c0bc1b5..2a40b0fa4 100644 --- a/ts/components/session/menu/Menu.tsx +++ b/ts/components/session/menu/Menu.tsx @@ -4,11 +4,7 @@ import { NotificationForConvoOption, TimerOption } from '../../conversation/Conv import { Item, Submenu } from 'react-contexify'; import { ConversationNotificationSettingType } from '../../../models/conversation'; import { useDispatch } from 'react-redux'; -import { - adminLeaveClosedGroup, - changeNickNameModal, - updateConfirmModal, -} from '../../../state/ducks/modalDialog'; +import { changeNickNameModal, updateConfirmModal } from '../../../state/ducks/modalDialog'; import { getConversationController } from '../../../session/conversations'; import { blockConvoById, @@ -134,6 +130,8 @@ export function getDeleteContactMenuItem( isKickedFromGroup: boolean | undefined, conversationId: string ): JSX.Element | null { + const dispatch = useDispatch(); + if ( showDeleteContact( Boolean(isMe), @@ -150,7 +148,6 @@ export function getDeleteContactMenuItem( menuItemText = window.i18n('delete'); } - const dispatch = useDispatch(); const onClickClose = () => { dispatch(updateConfirmModal(null)); }; diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 7eb666638..b9dfb055a 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -26,6 +26,7 @@ import { actions as conversationActions, ConversationType as ReduxConversationType, LastMessageStatusType, + MessageModelProps, } from '../state/ducks/conversations'; import { ExpirationTimerUpdateMessage } from '../session/messages/outgoing/controlMessage/ExpirationTimerUpdateMessage'; import { TypingMessage } from '../session/messages/outgoing/controlMessage/TypingMessage'; @@ -885,7 +886,7 @@ export class ConversationModel extends Backbone.Model { window.inboxStore?.dispatch( conversationActions.messageAdded({ conversationKey: this.id, - messageModel: model, + messageModelProps: model.getProps(), }) ); const unreadCount = await this.getUnreadCount(); @@ -939,11 +940,12 @@ export class ConversationModel extends Backbone.Model { const oldUnreadNowReadAttrs = oldUnreadNowRead.map(m => m.attributes); await saveMessages(oldUnreadNowReadAttrs); + const allProps: Array = []; for (const nowRead of oldUnreadNowRead) { - nowRead.generateProps(false); + allProps.push(nowRead.generateProps(false)); } - window.inboxStore?.dispatch(conversationActions.messagesChanged(oldUnreadNowRead)); + window.inboxStore?.dispatch(conversationActions.messagesChanged(allProps)); // Some messages we're marking read are local notifications with no sender read = _.filter(read, m => Boolean(m.sender)); diff --git a/ts/models/message.ts b/ts/models/message.ts index 32cd705f2..48416b100 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -11,10 +11,11 @@ import { PubKey } from '../../ts/session/types'; import { UserUtils } from '../../ts/session/utils'; import { DataExtractionNotificationMsg, - DataExtractionNotificationProps, fillMessageAttributesWithDefaults, MessageAttributes, MessageAttributesOptionals, + MessageModelType, + PropsForDataExtractionNotification, } from './messageType'; import autoBind from 'auto-bind'; @@ -23,7 +24,11 @@ import { ConversationModel, ConversationTypeEnum } from './conversation'; import { actions as conversationActions, FindAndFormatContactType, + LastMessageStatusType, + MessageModelProps, + MessagePropsDetails, PropsForExpirationTimer, + PropsForGroupInvitation, PropsForGroupUpdate, PropsForGroupUpdateAdd, PropsForGroupUpdateArray, @@ -31,6 +36,8 @@ import { PropsForGroupUpdateKicked, PropsForGroupUpdateName, PropsForGroupUpdateRemove, + PropsForMessage, + PropsForSearchResults, } from '../state/ducks/conversations'; import { VisibleMessage } from '../session/messages/outgoing/visibleMessage/VisibleMessage'; import { buildSyncMessage } from '../session/utils/syncUtils'; @@ -49,13 +56,6 @@ import { isUsFromCache } from '../session/utils/User'; import { perfEnd, perfStart } from '../session/utils/Performance'; export class MessageModel extends Backbone.Model { - public propsForTimerNotification: any; - public propsForGroupNotification: any; - public propsForGroupInvitation: any; - public propsForDataExtractionNotification?: DataExtractionNotificationProps; - public propsForSearchResult: any; - public propsForMessage: any; - constructor(attributes: MessageAttributesOptionals) { const filledAttrs = fillMessageAttributesWithDefaults(attributes); super(filledAttrs); @@ -80,21 +80,18 @@ export class MessageModel extends Backbone.Model { window.contextMenuShown = false; - this.generateProps(false); + // this.generateProps(false); } - // Keep props ready - public generateProps(triggerEvent = true) { + public getProps(): MessageModelProps { const propsForTimerNotification = this.getPropsForTimerNotification(); const propsForGroupNotification = this.getPropsForGroupNotification(); const propsForGroupInvitation = this.getPropsForGroupInvitation(); - const propsForDataExtractionNotification = this.isDataExtractionNotification() - ? this.getPropsForDataExtractionNotification() - : null; + const propsForDataExtractionNotification = this.getPropsForDataExtractionNotification(); const propsForSearchResult = this.getPropsForSearchResult(); const propsForMessage = this.getPropsForMessage(); - const messageProps = { + const messageProps: MessageModelProps = { propsForMessage, propsForSearchResult, propsForDataExtractionNotification, @@ -102,10 +99,16 @@ export class MessageModel extends Backbone.Model { propsForGroupNotification, propsForTimerNotification, }; + return messageProps; + } + // Keep props ready + public generateProps(triggerEvent = true): MessageModelProps { + const messageProps = this.getProps(); if (triggerEvent) { window.inboxStore?.dispatch(conversationActions.messageChanged(messageProps)); } + return messageProps; } public idForLogging() { @@ -284,7 +287,7 @@ export class MessageModel extends Backbone.Model { await window.Signal.Migrations.deleteExternalMessageFiles(this.attributes); } - public getPropsForTimerNotification(): PropsForExpirationTimer { + public getPropsForTimerNotification(): PropsForExpirationTimer | null { if (!this.isExpirationTimerUpdate()) { return null; } @@ -294,9 +297,7 @@ export class MessageModel extends Backbone.Model { } const { expireTimer, fromSync, source } = timerUpdate; - const timespan = window.Whisper.ExpirationTimerOptions.getName(expireTimer || 0) as - | string - | null; + const timespan = window.Whisper.ExpirationTimerOptions.getName(expireTimer || 0) as string; const disabled = !expireTimer; const basicProps: PropsForExpirationTimer = { @@ -309,7 +310,7 @@ export class MessageModel extends Backbone.Model { return basicProps; } - public getPropsForGroupInvitation() { + public getPropsForGroupInvitation(): PropsForGroupInvitation | null { if (!this.isGroupInvitation()) { return null; } @@ -332,18 +333,20 @@ export class MessageModel extends Backbone.Model { serverName: invitation.name, url: serverAddress, direction, - onJoinClick: () => { - acceptOpenGroupInvitation(invitation.url, invitation.name); - }, + acceptUrl: invitation.url, + messageId: this.id as string, }; } - public getPropsForDataExtractionNotification(): DataExtractionNotificationProps | undefined { + public getPropsForDataExtractionNotification(): PropsForDataExtractionNotification | null { + if (!this.isDataExtractionNotification()) { + return null; + } const dataExtractionNotification = this.get('dataExtractionNotification'); if (!dataExtractionNotification) { window.log.warn('dataExtractionNotification should not happen'); - return; + return null; } const contact = this.findAndFormatContact(dataExtractionNotification.source); @@ -361,8 +364,11 @@ export class MessageModel extends Backbone.Model { public findAndFormatContact(pubkey: string): FindAndFormatContactType { const contactModel = this.findContact(pubkey); let profileName; - if (pubkey === window.storage.get('primaryDevicePubKey')) { + let isMe = false; + UserUtils.getOurPubKeyStrFromCache(); + if (pubkey === UserUtils.getOurPubKeyStrFromCache()) { profileName = window.i18n('you'); + isMe = true; } else { profileName = contactModel ? contactModel.getProfileName() : null; } @@ -373,10 +379,11 @@ export class MessageModel extends Backbone.Model { name: (contactModel ? contactModel.getName() : null) as string | null, profileName: profileName as string | null, title: (contactModel ? contactModel.getTitle() : null) as string | null, + isMe, }; } - public getPropsForGroupNotification(): PropsForGroupUpdate { + public getPropsForGroupNotification(): PropsForGroupUpdate | null { if (!this.isGroupUpdate()) { return null; } @@ -465,7 +472,7 @@ export class MessageModel extends Backbone.Model { }; } - public getMessagePropStatus() { + public getMessagePropStatus(): LastMessageStatusType { if (this.hasErrors()) { return 'error'; } @@ -492,42 +499,31 @@ export class MessageModel extends Backbone.Model { return 'sending'; } - public getPropsForSearchResult() { + public getPropsForSearchResult(): PropsForSearchResults { const fromNumber = this.getSource(); const from = this.findAndFormatContact(fromNumber); - if (fromNumber === UserUtils.getOurPubKeyStrFromCache()) { - (from as any).isMe = true; - } const toNumber = this.get('conversationId'); - let to = this.findAndFormatContact(toNumber) as any; - if (toNumber === UserUtils.getOurPubKeyStrFromCache()) { - to.isMe = true; - } else if (fromNumber === toNumber) { - to = { - isMe: true, - }; - } + const to = this.findAndFormatContact(toNumber); return { from, to, - // isSelected: this.isSelected, - - id: this.id, + id: this.id as string, conversationId: this.get('conversationId'), receivedAt: this.get('received_at'), snippet: this.get('snippet'), }; } - public getPropsForMessage(options: any = {}) { - const phoneNumber = this.getSource(); - const contact = this.findAndFormatContact(phoneNumber); - const contactModel = this.findContact(phoneNumber); + public getPropsForMessage(options: any = {}): PropsForMessage { + const ourPubkey = UserUtils.getOurPubKeyStrFromCache(); + const sender = this.getSource(); + const senderContact = this.findAndFormatContact(sender); + const senderContactModel = this.findContact(sender); - const authorAvatarPath = contactModel ? contactModel.getAvatarPath() : null; + const authorAvatarPath = senderContactModel ? senderContactModel.getAvatarPath() : null; const expirationLength = this.get('expireTimer') * 1000; const expireTimerStart = this.get('expirationStartTimestamp'); @@ -536,26 +532,35 @@ export class MessageModel extends Backbone.Model { const conversation = this.getConversation(); - const convoId = conversation ? conversation.id : undefined; const isGroup = !!conversation && !conversation.isPrivate(); const isPublic = !!this.get('isPublic'); const isPublicOpenGroupV2 = isOpenGroupV2(this.getConversation()?.id || ''); const attachments = this.get('attachments') || []; const isTrustedForAttachmentDownload = this.isTrustedForAttachmentDownload(); + const groupAdmins = (isGroup && conversation?.get('groupAdmins')) || []; + const weAreAdmin = groupAdmins.includes(ourPubkey) || false; + // a message is deletable if + // either we sent it, + // or the convo is not a public one (in this case, we will only be able to delete for us) + // or the convo is public and we are an admin + const isDeletable = sender === ourPubkey || !isPublic || (isPublic && !!weAreAdmin); - return { + const isSenderAdmin = groupAdmins.includes(sender); + + const props: PropsForMessage = { text: this.createNonBreakingLastSeparator(this.get('body')), - id: this.id, - direction: this.isIncoming() ? 'incoming' : 'outgoing', + id: this.id as string, + direction: (this.isIncoming() ? 'incoming' : 'outgoing') as MessageModelType, timestamp: this.get('sent_at'), + receivedAt: this.get('received_at'), serverTimestamp: this.get('serverTimestamp'), status: this.getMessagePropStatus(), - authorName: contact.name, - authorProfileName: contact.profileName, - authorPhoneNumber: contact.phoneNumber, + authorName: senderContact.name, + authorProfileName: senderContact.profileName, + authorPhoneNumber: senderContact.phoneNumber, conversationType: isGroup ? ConversationTypeEnum.GROUP : ConversationTypeEnum.PRIVATE, - convoId, + convoId: this.get('conversationId'), attachments: attachments .filter((attachment: any) => !attachment.error) .map((attachment: any) => this.getPropsForAttachment(attachment)), @@ -567,12 +572,14 @@ export class MessageModel extends Backbone.Model { expirationTimestamp, isPublic, isOpenGroupV2: isPublicOpenGroupV2, - isKickedFromGroup: conversation && conversation.get('isKickedFromGroup'), + isKickedFromGroup: conversation?.get('isKickedFromGroup'), isTrustedForAttachmentDownload, - - onRetrySend: this.retrySend, - markRead: this.markRead, + weAreAdmin, + isDeletable, + isSenderAdmin, }; + + return props; } public createNonBreakingLastSeparator(text?: string) { @@ -639,7 +646,6 @@ export class MessageModel extends Backbone.Model { } public getPropsForQuote(options: any = {}) { - const { noClick } = options; const quote = this.get('quote'); if (!quote) { @@ -652,16 +658,6 @@ export class MessageModel extends Backbone.Model { const authorName = contact ? contact.getContactProfileNameOrShortenedPubKey() : null; const isFromMe = contact ? contact.id === UserUtils.getOurPubKeyStrFromCache() : false; - const onClick = noClick - ? null - : (event: any) => { - event.stopPropagation(); - this.trigger('scroll-to-message', { - author, - id, - referencedMessageNotFound, - }); - }; const firstAttachment = quote.attachments && quote.attachments[0]; @@ -672,12 +668,18 @@ export class MessageModel extends Backbone.Model { authorPhoneNumber: author, messageId: id, authorName, - onClick, referencedMessageNotFound, }; } - public getPropsForAttachment(attachment: any) { + public getPropsForAttachment(attachment: { + path?: string; + pending?: boolean; + flags: number; + size: number; + screenshot: any; + thumbnail: any; + }) { if (!attachment) { return null; } @@ -709,7 +711,7 @@ export class MessageModel extends Backbone.Model { }; } - public async getPropsForMessageDetail() { + public async getPropsForMessageDetail(): Promise { // We include numbers we didn't successfully send to so we can display errors. // Older messages don't have the recipients included on the message, so we fall // back to the conversation's current recipients @@ -754,12 +756,11 @@ export class MessageModel extends Backbone.Model { finalContacts, contact => `${contact.isPrimaryDevice ? '0' : '1'}${contact.phoneNumber}` ); - - return { + const toRet: MessagePropsDetails = { sentAt: this.get('sent_at'), receivedAt: this.get('received_at'), message: { - ...this.propsForMessage, + ...this.getPropsForMessage(), disableMenu: true, // To ensure that group avatar doesn't show up conversationType: ConversationTypeEnum.PRIVATE, @@ -767,6 +768,8 @@ export class MessageModel extends Backbone.Model { errors, contacts: sortedContacts, }; + + return toRet; } public copyPubKey() { diff --git a/ts/models/messageType.ts b/ts/models/messageType.ts index cf945bfaa..9cf7745cc 100644 --- a/ts/models/messageType.ts +++ b/ts/models/messageType.ts @@ -109,7 +109,7 @@ export interface DataExtractionNotificationMsg { referencedAttachmentTimestamp: number; // the attachment timestamp he screenshot } -export type DataExtractionNotificationProps = DataExtractionNotificationMsg & { +export type PropsForDataExtractionNotification = DataExtractionNotificationMsg & { name: string; }; @@ -247,7 +247,6 @@ export interface MessageRegularProps { onClickLinkPreview?: (url: string) => void; onSelectMessage: (messageId: string) => void; onReply?: (messagId: number) => void; - onRetrySend?: () => void; onDownload?: (attachment: AttachmentType) => void; onDeleteMessage: (messageId: string) => void; onShowDetail: () => void; diff --git a/ts/receiver/errors.ts b/ts/receiver/errors.ts index 779555af8..15f68636c 100644 --- a/ts/receiver/errors.ts +++ b/ts/receiver/errors.ts @@ -38,7 +38,7 @@ export async function onError(ev: any) { window.inboxStore?.dispatch( conversationActions.messageAdded({ conversationKey: conversation.id, - messageModel: message, + messageModelProps: message.getProps(), }) ); diff --git a/ts/receiver/queuedJob.ts b/ts/receiver/queuedJob.ts index cc951424f..1ee9316c3 100644 --- a/ts/receiver/queuedJob.ts +++ b/ts/receiver/queuedJob.ts @@ -440,7 +440,7 @@ export async function handleMessageJob( window.inboxStore?.dispatch( conversationActions.messageAdded({ conversationKey: conversation.id, - messageModel: message, + messageModelProps: message.getProps(), }) ); getMessageController().register(message.id, message); diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index bf04a9f2c..64f1db8c5 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -6,50 +6,22 @@ import { getConversationController } from '../../session/conversations'; import { MessageModel } from '../../models/message'; import { getMessagesByConversation } from '../../data/data'; import { ConversationTypeEnum } from '../../models/conversation'; -import { MessageDeliveryStatus } from '../../models/messageType'; - -// State - -export type MessageType = { - id: string; - conversationId: string; - receivedAt: number; - - snippet: string; - - from: { - phoneNumber: string; - isMe?: boolean; - name?: string; - color?: string; - profileName?: string; - avatarPath?: string; - }; - - to: { - groupName?: string; - phoneNumber: string; - isMe?: boolean; - name?: string; - profileName?: string; - }; - - isSelected?: boolean; +import { + MessageDeliveryStatus, + MessageModelType, + PropsForDataExtractionNotification, +} from '../../models/messageType'; + +export type MessageModelProps = { + propsForMessage: PropsForMessage; + propsForSearchResult: PropsForSearchResults | null; + propsForGroupInvitation: PropsForGroupInvitation | null; + propsForTimerNotification: PropsForExpirationTimer | null; + propsForDataExtractionNotification: PropsForDataExtractionNotification | null; + propsForGroupNotification: PropsForGroupUpdate | null; }; -export type MessageTypeInConvo = { - id: string; - conversationId: string; - attributes: any; - propsForMessage: Object; - propsForSearchResult: Object; - propsForGroupInvitation: Object; - propsForTimerNotification: Object; - propsForGroupNotification: Object; - firstMessageOfSeries: boolean; - receivedAt: number; - getPropsForMessageDetail(): Promise; -}; +export type MessagePropsDetails = {}; export type LastMessageStatusType = MessageDeliveryStatus | null; @@ -59,10 +31,11 @@ export type FindAndFormatContactType = { name: string | null; profileName: string | null; title: string | null; + isMe: boolean; }; export type PropsForExpirationTimer = { - timespan: string | null; + timespan: string; disabled: boolean; phoneNumber: string; avatarPath: string | null; @@ -70,7 +43,7 @@ export type PropsForExpirationTimer = { profileName: string | null; title: string | null; type: 'fromMe' | 'fromSync' | 'fromOther'; -} | null; +}; export type PropsForGroupUpdateGeneral = { type: 'general'; @@ -98,17 +71,64 @@ export type PropsForGroupUpdateName = { newName: string; }; -export type PropsForGroupUpdateArray = Array< +export type PropsForGroupUpdateType = | PropsForGroupUpdateGeneral | PropsForGroupUpdateAdd | PropsForGroupUpdateKicked | PropsForGroupUpdateName - | PropsForGroupUpdateRemove ->; + | PropsForGroupUpdateRemove; + +export type PropsForGroupUpdateArray = Array; export type PropsForGroupUpdate = { changes: PropsForGroupUpdateArray; -} | null; +}; + +export type PropsForGroupInvitation = { + serverName: string; + url: string; + direction: MessageModelType; + acceptUrl: string; + messageId: string; +}; + +export type PropsForSearchResults = { + from: FindAndFormatContactType; + to: FindAndFormatContactType; + id: string; + conversationId: string; + receivedAt: number | undefined; + snippet?: string; //not sure about the type of snippet +}; + +export type PropsForMessage = { + text: string | null; + id: string; + direction: MessageModelType; + timestamp: number | undefined; + receivedAt: number | undefined; + serverTimestamp: number | undefined; + status: LastMessageStatusType; + authorName: string | null; + authorProfileName: string | null; + authorPhoneNumber: string; + conversationType: ConversationTypeEnum; + convoId: string; + attachments: any; + previews: any; + quote: any; + authorAvatarPath: string | null; + isUnread: boolean; + expirationLength: number; + expirationTimestamp: number | null; + isPublic: boolean; + isOpenGroupV2: boolean; + isKickedFromGroup: boolean | undefined; + isTrustedForAttachmentDownload: boolean; + weAreAdmin: boolean; + isSenderAdmin: boolean; + isDeletable: boolean; +}; export type LastMessageType = { status: LastMessageStatusType; @@ -148,13 +168,13 @@ export type ConversationLookupType = { export type ConversationsStateType = { conversationLookup: ConversationLookupType; selectedConversation?: string; - messages: Array; + messages: Array; }; async function getMessages( conversationKey: string, numMessages: number -): Promise> { +): Promise> { const conversation = getConversationController().get(conversationKey); if (!conversation) { // no valid conversation, early return @@ -178,55 +198,78 @@ async function getMessages( }); // Set first member of series here. - const messageModels = messageSet.models; + const messageModelsProps: Array = messageSet.models.map(m => { + return { ...m.getProps(), firstMessageOfSeries: true }; + }); const isPublic = conversation.isPublic(); - const messagesPickedUp = messageModels.map(makeMessageTypeFromMessageModel); - const sortedMessage = sortMessages(messagesPickedUp, isPublic); + const sortedMessageProps = sortMessages(messageModelsProps, isPublic); // no need to do that `firstMessageOfSeries` on a private chat if (conversation.isPrivate()) { - return sortedMessage; + return sortedMessageProps; } - return updateFirstMessageOfSeries(sortedMessage); + return updateFirstMessageOfSeries(sortedMessageProps); } -const updateFirstMessageOfSeries = (messageModels: Array) => { +export type SortedMessageModelProps = MessageModelProps & { + firstMessageOfSeries: boolean; +}; + +const updateFirstMessageOfSeries = ( + messageModelsProps: Array +): Array => { // messages are got from the more recent to the oldest, so we need to check if // the next messages in the list is still the same author. // The message is the first of the series if the next message is not from the same author - for (let i = 0; i < messageModels.length; i++) { + const sortedMessageProps: Array = []; + for (let i = 0; i < messageModelsProps.length; i++) { // Handle firstMessageOfSeries for conditional avatar rendering let firstMessageOfSeries = true; - const currentSender = messageModels[i].propsForMessage?.authorPhoneNumber; + const currentSender = messageModelsProps[i].propsForMessage?.authorPhoneNumber; const nextSender = - i < messageModels.length - 1 - ? messageModels[i + 1].propsForMessage?.authorPhoneNumber + i < messageModelsProps.length - 1 + ? messageModelsProps[i + 1].propsForMessage?.authorPhoneNumber : undefined; if (i >= 0 && currentSender === nextSender) { firstMessageOfSeries = false; } - if (messageModels[i].propsForMessage) { - messageModels[i].propsForMessage.firstMessageOfSeries = firstMessageOfSeries; - } + + sortedMessageProps.push({ ...messageModelsProps[i], firstMessageOfSeries }); } - return messageModels; + return sortedMessageProps; +}; + +type FetchedMessageResults = { + conversationKey: string; + messagesProps: Array; }; const fetchMessagesForConversation = createAsyncThunk( 'messages/fetchByConversationKey', - async ({ conversationKey, count }: { conversationKey: string; count: number }) => { + async ({ + conversationKey, + count, + }: { + conversationKey: string; + count: number; + }): Promise => { const beforeTimestamp = Date.now(); - const messages = await getMessages(conversationKey, count); + const messagesProps = await getMessages(conversationKey, count); const afterTimestamp = Date.now(); const time = afterTimestamp - beforeTimestamp; - window?.log?.info(`Loading ${messages.length} messages took ${time}ms to load.`); + window?.log?.info(`Loading ${messagesProps.length} messages took ${time}ms to load.`); return { conversationKey, - messages, + messagesProps: messagesProps.map(m => { + return { + ...m, + firstMessageOfSeries: true, + }; + }), }; } ); @@ -266,17 +309,17 @@ export type MessageExpiredActionType = { }; export type MessageChangedActionType = { type: 'MESSAGE_CHANGED'; - payload: MessageModel; + payload: MessageModelProps; }; export type MessagesChangedActionType = { type: 'MESSAGES_CHANGED'; - payload: Array; + payload: Array; }; export type MessageAddedActionType = { type: 'MESSAGE_ADDED'; payload: { conversationKey: string; - messageModel: MessageModel; + messageModelProps: MessageModelProps; }; }; export type MessageDeletedActionType = { @@ -304,7 +347,7 @@ export type FetchMessagesForConversationType = { type: 'messages/fetchByConversationKey/fulfilled'; payload: { conversationKey: string; - messages: Array; + messages: Array; }; }; @@ -389,32 +432,32 @@ function messageExpired({ }; } -function messageChanged(messageModel: MessageModel): MessageChangedActionType { +function messageChanged(messageModelProps: MessageModelProps): MessageChangedActionType { return { type: 'MESSAGE_CHANGED', - payload: messageModel, + payload: messageModelProps, }; } -function messagesChanged(messageModels: Array): MessagesChangedActionType { +function messagesChanged(messageModelsProps: Array): MessagesChangedActionType { return { type: 'MESSAGES_CHANGED', - payload: messageModels, + payload: messageModelsProps, }; } function messageAdded({ conversationKey, - messageModel, + messageModelProps, }: { conversationKey: string; - messageModel: MessageModel; + messageModelProps: MessageModelProps; }): MessageAddedActionType { return { type: 'MESSAGE_ADDED', payload: { conversationKey, - messageModel, + messageModelProps, }, }; } @@ -464,31 +507,6 @@ export function openConversationExternal( // Reducer -const toPickFromMessageModel = [ - 'attributes', - 'id', - 'propsForSearchResult', - 'propsForMessage', - 'receivedAt', - 'conversationId', - 'firstMessageOfSeries', - 'propsForGroupInvitation', - 'propsForTimerNotification', - 'propsForGroupNotification', - 'propsForDataExtractionNotification', - // FIXME below are what is needed to fetch on the fly messageDetails. This is not the react way - 'getPropsForMessageDetail', - 'get', - 'getConversation', - 'isIncoming', - 'findAndFormatContact', - 'findContact', - 'getStatus', - 'getMessagePropStatus', - 'hasErrors', - 'isOutgoing', -]; - function getEmptyState(): ConversationsStateType { return { conversationLookup: {}, @@ -496,14 +514,10 @@ function getEmptyState(): ConversationsStateType { }; } -const makeMessageTypeFromMessageModel = (message: MessageModel) => { - return _.pick(message as any, toPickFromMessageModel) as MessageTypeInConvo; -}; - function sortMessages( - messages: Array, + messages: Array, isPublic: boolean -): Array { +): Array { // we order by serverTimestamp for public convos // be sure to update the sorting order to fetch messages from the DB too at getMessagesByConversation if (isPublic) { @@ -511,7 +525,7 @@ function sortMessages( (a: any, b: any) => b.attributes.serverTimestamp - a.attributes.serverTimestamp ); } - if (messages.some(n => !n.attributes.sent_at && !n.attributes.received_at)) { + if (messages.some(n => !n.propsForMessage.timestamp && !n.propsForMessage.receivedAt)) { throw new Error('Found some messages without any timestamp set'); } @@ -528,10 +542,12 @@ function sortMessages( function handleMessageAdded(state: ConversationsStateType, action: MessageAddedActionType) { const { messages } = state; - const { conversationKey, messageModel } = action.payload; + const { conversationKey, messageModelProps: addedMessageProps } = action.payload; if (conversationKey === state.selectedConversation) { - const addedMessage = makeMessageTypeFromMessageModel(messageModel); - const messagesWithNewMessage = [...messages, addedMessage]; + const messagesWithNewMessage = [ + ...messages, + { ...addedMessageProps, firstMessageOfSeries: true }, + ]; const convo = state.conversationLookup[state.selectedConversation]; const isPublic = convo?.isPublic || false; @@ -551,9 +567,11 @@ function handleMessageAdded(state: ConversationsStateType, action: MessageAddedA function handleMessageChanged(state: ConversationsStateType, action: MessageChangedActionType) { const { payload } = action; - const messageInStoreIndex = state?.messages?.findIndex(m => m.id === payload.id); + const messageInStoreIndex = state?.messages?.findIndex( + m => m.propsForMessage.id === payload.propsForMessage.id + ); if (messageInStoreIndex >= 0) { - const changedMessage = _.pick(payload as any, toPickFromMessageModel) as MessageTypeInConvo; + const changedMessage = { ...payload, firstMessageOfSeries: true }; // we cannot edit the array directly, so slice the first part, insert our edited message, and slice the second part const editedMessages = [ ...state.messages.slice(0, messageInStoreIndex), @@ -561,7 +579,7 @@ function handleMessageChanged(state: ConversationsStateType, action: MessageChan ...state.messages.slice(messageInStoreIndex + 1), ]; - const convo = state.conversationLookup[payload.get('conversationId')]; + const convo = state.conversationLookup[payload.propsForMessage.convoId]; const isPublic = convo?.isPublic || false; // reorder the messages depending on the timestamp (we might have an updated serverTimestamp now) const sortedMessage = sortMessages(editedMessages, isPublic); @@ -598,7 +616,7 @@ function handleMessageExpiredOrDeleted( if (conversationKey === state.selectedConversation) { // search if we find this message id. // we might have not loaded yet, so this case might not happen - const messageInStoreIndex = state?.messages.findIndex(m => m.id === messageId); + const messageInStoreIndex = state?.messages.findIndex(m => m.propsForMessage.id === messageId); if (messageInStoreIndex >= 0) { // we cannot edit the array directly, so slice the first part, and slice the second part, // keeping the index removed out @@ -714,15 +732,12 @@ export function reducer( // this is called once the messages are loaded from the db for the currently selected conversation if (action.type === fetchMessagesForConversation.fulfilled.type) { - const { messages, conversationKey } = action.payload as any; + const { messagesProps, conversationKey } = action.payload as FetchedMessageResults; // double check that this update is for the shown convo if (conversationKey === state.selectedConversation) { - const lightMessages = messages.map((m: any) => _.pick(m, toPickFromMessageModel)) as Array< - MessageTypeInConvo - >; return { ...state, - messages: lightMessages, + messages: { ...messagesProps }, }; } return state; diff --git a/ts/state/ducks/search.ts b/ts/state/ducks/search.ts index 2c64a6304..46a602b00 100644 --- a/ts/state/ducks/search.ts +++ b/ts/state/ducks/search.ts @@ -8,7 +8,7 @@ import { makeLookup } from '../../util/makeLookup'; import { ConversationType, MessageExpiredActionType, - MessageType, + PropsForSearchResults, RemoveAllConversationsActionType, SelectedConversationChangedActionType, } from './conversations'; @@ -23,10 +23,10 @@ export type SearchStateType = { query: string; normalizedPhoneNumber?: string; // We need to store messages here, because they aren't anywhere else in state - messages: Array; + messages: Array; selectedMessage?: string; messageLookup: { - [key: string]: MessageType; + [key: string]: PropsForSearchResults; }; // For conversations we store just the id, and pull conversation props in the selector conversations: Array; @@ -37,7 +37,7 @@ export type SearchStateType = { type SearchResultsPayloadType = { query: string; normalizedPhoneNumber?: string; - messages: Array; + messages: Array; conversations: Array; contacts: Array; }; @@ -211,7 +211,7 @@ function getAdvancedSearchOptionsFromQuery(query: string): AdvancedSearchOptions return filters; } -const getMessageProps = (messages: Array) => { +const getMessageProps = (messages: Array) => { if (!messages || !messages.length) { return []; } @@ -224,7 +224,7 @@ const getMessageProps = (messages: Array) => { const model = new MessageModel(overridenProps); - return model.propsForSearchResult; + return model.getPropsForSearchResult(); }); }; diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index b054f312a..3a845b76e 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -5,7 +5,8 @@ import { ConversationLookupType, ConversationsStateType, ConversationType, - MessageTypeInConvo, + MessageModelProps, + SortedMessageModelProps, } from '../ducks/conversations'; import { getIntl, getOurNumber } from './user'; @@ -46,7 +47,7 @@ export const getOurPrimaryConversation = createSelector( export const getMessagesOfSelectedConversation = createSelector( getConversations, - (state: ConversationsStateType): Array => state.messages + (state: ConversationsStateType): Array => state.messages ); function getConversationTitle(conversation: ConversationType, testingi18n?: LocalizerType): string {