diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 7f5e8aad73..675c4dac62 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -35,8 +35,10 @@ const items: Record = { id: 'id-1', direction: 'incoming', timestamp: Date.now(), - authorPhoneNumber: '(202) 555-2001', - authorColor: 'green', + author: { + phoneNumber: '(202) 555-2001', + color: 'green', + }, text: '🔥', }, }, @@ -47,7 +49,9 @@ const items: Record = { conversationType: 'group', direction: 'incoming', timestamp: Date.now(), - authorColor: 'green', + author: { + color: 'green', + }, text: 'Hello there from the new world! http://somewhere.com', }, }, @@ -70,7 +74,9 @@ const items: Record = { collapseMetadata: true, direction: 'incoming', timestamp: Date.now(), - authorColor: 'red', + author: { + color: 'red', + }, text: 'Hello there from the new world!', }, }, @@ -154,7 +160,9 @@ const items: Record = { direction: 'outgoing', timestamp: Date.now(), status: 'sent', - authorColor: 'pink', + author: { + color: 'pink', + }, text: '🔥', }, }, @@ -165,7 +173,9 @@ const items: Record = { direction: 'outgoing', timestamp: Date.now(), status: 'read', - authorColor: 'pink', + author: { + color: 'pink', + }, text: 'Hello there from the new world! http://somewhere.com', }, }, @@ -187,7 +197,9 @@ const items: Record = { direction: 'outgoing', status: 'sent', timestamp: Date.now(), - authorColor: 'blue', + author: { + color: 'blue', + }, text: 'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.', }, @@ -334,7 +346,14 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ haveNewest: boolean('haveNewest', overrideProps.haveNewest !== false), haveOldest: boolean('haveOldest', overrideProps.haveOldest !== false), - isLoadingMessages: false, + isIncomingMessageRequest: boolean( + 'isIncomingMessageRequest', + overrideProps.isIncomingMessageRequest === true + ), + isLoadingMessages: boolean( + 'isLoadingMessages', + overrideProps.isLoadingMessages === false + ), items: overrideProps.items || Object.keys(items), resetCounter: 0, scrollToIndex: overrideProps.scrollToIndex, @@ -367,6 +386,40 @@ story.add('Oldest and Newest', () => { return ; }); +story.add('With active message request', () => { + const props = createProps({ + isIncomingMessageRequest: true, + }); + + return ; +}); + +story.add('Without Newest Message', () => { + const props = createProps({ + haveNewest: false, + }); + + return ; +}); + +story.add('Without newest message, active message request', () => { + const props = createProps({ + haveOldest: false, + isIncomingMessageRequest: true, + }); + + return ; +}); + +story.add('Without Oldest Message', () => { + const props = createProps({ + haveOldest: false, + scrollToIndex: -1, + }); + + return ; +}); + story.add('Empty (just hero)', () => { const props = createProps({ items: [], @@ -400,23 +453,6 @@ story.add('Typing Indicator', () => { return ; }); -story.add('Without Newest Message', () => { - const props = createProps({ - haveNewest: false, - }); - - return ; -}); - -story.add('Without Oldest Message', () => { - const props = createProps({ - haveOldest: false, - scrollToIndex: -1, - }); - - return ; -}); - story.add('With invited contacts for a newly-created group', () => { const props = createProps({ invitedContactsForNewlyCreatedGroup: [ diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index d52243f3c5..0e7afef0cc 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -57,9 +57,10 @@ export type PropsDataType = { type PropsHousekeepingType = { id: string; - unreadCount?: number; - typingContact?: unknown; isGroupV1AndDisabled?: boolean; + isIncomingMessageRequest: boolean; + typingContact?: unknown; + unreadCount?: number; selectedMessageId?: string; invitedContactsForNewlyCreatedGroup: Array; @@ -198,11 +199,16 @@ export class Timeline extends React.PureComponent { constructor(props: PropsType) { super(props); - const { scrollToIndex } = this.props; - const oneTimeScrollRow = this.getLastSeenIndicatorRow(); + const { scrollToIndex, isIncomingMessageRequest } = this.props; + const oneTimeScrollRow = isIncomingMessageRequest + ? undefined + : this.getLastSeenIndicatorRow(); + + // We only stick to the bottom if this is not an incoming message request. + const atBottom = !isIncomingMessageRequest; this.state = { - atBottom: true, + atBottom, atTop: false, oneTimeScrollRow, propScrollToIndex: scrollToIndex, @@ -364,6 +370,7 @@ export class Timeline extends React.PureComponent { haveNewest, haveOldest, id, + isIncomingMessageRequest, setIsNearBottom, setLoadCountdownStart, } = this.props; @@ -386,8 +393,12 @@ export class Timeline extends React.PureComponent { scrollHeight - clientHeight - scrollTop ); - const atBottom = - haveNewest && this.offsetFromBottom <= AT_BOTTOM_THRESHOLD; + // If there's an active message request, we won't stick to the bottom of the + // conversation as new messages come in. + const atBottom = isIncomingMessageRequest + ? false + : haveNewest && this.offsetFromBottom <= AT_BOTTOM_THRESHOLD; + const isNearBottom = haveNewest && this.offsetFromBottom <= NEAR_BOTTOM_THRESHOLD; const atTop = scrollTop <= AT_TOP_THRESHOLD; @@ -773,10 +784,12 @@ export class Timeline extends React.PureComponent { selectMessage(lastMessageId, id); } + const oneTimeScrollRow = + items && items.length > 0 ? items.length - 1 : undefined; + this.setState({ propScrollToIndex: undefined, - oneTimeScrollRow: undefined, - atBottom: true, + oneTimeScrollRow, }); }; @@ -850,8 +863,9 @@ export class Timeline extends React.PureComponent { prevState: Readonly ): void { const { - id, clearChangedMessages, + id, + isIncomingMessageRequest, items, messageHeightChangeIndex, oldestUnreadIndex, @@ -885,12 +899,17 @@ export class Timeline extends React.PureComponent { this.resize(); } - const oneTimeScrollRow = this.getLastSeenIndicatorRow(); + // We want to come in at the top of the conversation if it's a message request + const oneTimeScrollRow = isIncomingMessageRequest + ? undefined + : this.getLastSeenIndicatorRow(); + const atBottom = !isIncomingMessageRequest; + // TODO: DESKTOP-688 // eslint-disable-next-line react/no-did-update-set-state this.setState({ oneTimeScrollRow, - atBottom: true, + atBottom, propScrollToIndex: scrollToIndex, prevPropScrollToIndex: scrollToIndex, }); @@ -1009,13 +1028,13 @@ export class Timeline extends React.PureComponent { const { oneTimeScrollRow, atBottom, propScrollToIndex } = this.state; const rowCount = this.getRowCount(); - const targetMessage = isNumber(propScrollToIndex) + const targetMessageRow = isNumber(propScrollToIndex) ? this.fromItemIndexToRow(propScrollToIndex) : undefined; const scrollToBottom = atBottom ? rowCount - 1 : undefined; - if (isNumber(targetMessage)) { - return targetMessage; + if (isNumber(targetMessageRow)) { + return targetMessageRow; } if (isNumber(oneTimeScrollRow)) { diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index ca07023b1c..e6c9c5f8a8 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -157,6 +157,10 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { 'typingContact', 'isGroupV1AndDisabled', ]), + isIncomingMessageRequest: Boolean( + conversation.messageRequestsEnabled && + !conversation.acceptedMessageRequest + ), ...conversationMessages, invitedContactsForNewlyCreatedGroup: getInvitedContactsForNewlyCreatedGroup( state diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 572a5906bd..2a1ed3dc66 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -1068,8 +1068,10 @@ Whisper.ConversationView = Whisper.View.extend({ return finish; }, - async loadAndScroll(messageId: any, options: any) { - const { disableScroll } = options || {}; + async loadAndScroll( + messageId: string, + options?: { disableScroll?: boolean } + ) { const { messagesReset, setMessagesLoading, @@ -1110,7 +1112,8 @@ Whisper.ConversationView = Whisper.View.extend({ const cleaned = await this.cleanModels(all); this.model.messageCollection.reset(cleaned); - const scrollToMessageId = disableScroll ? undefined : messageId; + const scrollToMessageId = + options && options.disableScroll ? undefined : messageId; messagesReset( conversationId, @@ -1126,12 +1129,17 @@ Whisper.ConversationView = Whisper.View.extend({ } }, - async loadNewestMessages(newestMessageId: any, setFocus: any) { + async loadNewestMessages( + newestMessageId: string | undefined, + setFocus: boolean | undefined + ): Promise { const { messagesReset, setMessagesLoading, } = window.reduxActions.conversations; - const conversationId = this.model.id; + const { model }: { model: ConversationModel } = this; + + const conversationId = model.id; setMessagesLoading(conversationId, true); const finish = this.setInProgressFetch(); @@ -1158,6 +1166,15 @@ Whisper.ConversationView = Whisper.View.extend({ const metrics = await getMessageMetricsForConversation(conversationId); + // If this is a message request that has not yet been accepted, we always show the + // oldest messages, to ensure that the ConversationHero is shown. We don't want to + // scroll directly to the oldest message, because that could scroll the hero off + // the screen. + if (!newestMessageId && !model.getAccepted() && metrics.oldest) { + this.loadAndScroll(metrics.oldest.id, { disableScroll: true }); + return; + } + if (scrollToLatestUnread && metrics.oldestUnread) { this.loadAndScroll(metrics.oldestUnread.id, { disableScroll: !setFocus, @@ -1171,7 +1188,12 @@ Whisper.ConversationView = Whisper.View.extend({ }); const cleaned = await this.cleanModels(messages); - this.model.messageCollection.reset(cleaned); + assert( + model.messageCollection, + 'loadNewestMessages: model must have messageCollection' + ); + + model.messageCollection.reset(cleaned); const scrollToMessageId = setFocus && metrics.newest ? metrics.newest.id : undefined; @@ -1183,7 +1205,7 @@ Whisper.ConversationView = Whisper.View.extend({ const unboundedFetch = true; messagesReset( conversationId, - cleaned.map((model: any) => model.getReduxData()), + cleaned.map((messageModel: any) => messageModel.getReduxData()), metrics, scrollToMessageId, unboundedFetch