Scroll to bottom of conversation on message send

This commit is contained in:
Scott Nonnenberg 2021-11-02 19:00:54 -07:00 committed by GitHub
parent 254c87a1ac
commit 5bd7eda124
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 107 additions and 21 deletions

View file

@ -63,6 +63,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
draftText: overrideProps.draftText || undefined, draftText: overrideProps.draftText || undefined,
clearQuotedMessage: action('clearQuotedMessage'), clearQuotedMessage: action('clearQuotedMessage'),
getQuotedMessage: action('getQuotedMessage'), getQuotedMessage: action('getQuotedMessage'),
scrollToBottom: action('scrollToBottom'),
sortedGroupMembers: [], sortedGroupMembers: [],
// EmojiButton // EmojiButton
onPickEmoji: action('onPickEmoji'), onPickEmoji: action('onPickEmoji'),

View file

@ -118,6 +118,7 @@ export type OwnProps = Readonly<{
setQuotedMessage(message: undefined): unknown; setQuotedMessage(message: undefined): unknown;
shouldSendHighQualityAttachments: boolean; shouldSendHighQualityAttachments: boolean;
startRecording: () => unknown; startRecording: () => unknown;
scrollToBottom: (converstionId: string) => unknown;
theme: ThemeType; theme: ThemeType;
}>; }>;
@ -196,6 +197,7 @@ export const CompositionArea = ({
draftBodyRanges, draftBodyRanges,
clearQuotedMessage, clearQuotedMessage,
getQuotedMessage, getQuotedMessage,
scrollToBottom,
sortedGroupMembers, sortedGroupMembers,
// EmojiButton // EmojiButton
onPickEmoji, onPickEmoji,
@ -622,19 +624,21 @@ export const CompositionArea = ({
<div className="CompositionArea__input"> <div className="CompositionArea__input">
<CompositionInput <CompositionInput
i18n={i18n} i18n={i18n}
conversationId={conversationId}
clearQuotedMessage={clearQuotedMessage}
disabled={disabled} disabled={disabled}
large={large} draftBodyRanges={draftBodyRanges}
draftText={draftText}
getQuotedMessage={getQuotedMessage}
inputApi={inputApiRef} inputApi={inputApiRef}
large={large}
onDirtyChange={setDirty}
onEditorStateChange={onEditorStateChange}
onPickEmoji={onPickEmoji} onPickEmoji={onPickEmoji}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onEditorStateChange={onEditorStateChange}
onTextTooLong={onTextTooLong} onTextTooLong={onTextTooLong}
onDirtyChange={setDirty} scrollToBottom={scrollToBottom}
skinTone={skinTone} skinTone={skinTone}
draftText={draftText}
draftBodyRanges={draftBodyRanges}
clearQuotedMessage={clearQuotedMessage}
getQuotedMessage={getQuotedMessage}
sortedGroupMembers={sortedGroupMembers} sortedGroupMembers={sortedGroupMembers}
/> />
</div> </div>

View file

@ -20,6 +20,7 @@ const story = storiesOf('Components/CompositionInput', module);
const createProps = (overrideProps: Partial<Props> = {}): Props => ({ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
i18n, i18n,
conversationId: 'conversation-id',
disabled: boolean('disabled', overrideProps.disabled || false), disabled: boolean('disabled', overrideProps.disabled || false),
onSubmit: action('onSubmit'), onSubmit: action('onSubmit'),
onEditorStateChange: action('onEditorStateChange'), onEditorStateChange: action('onEditorStateChange'),
@ -30,6 +31,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
getQuotedMessage: action('getQuotedMessage'), getQuotedMessage: action('getQuotedMessage'),
onPickEmoji: action('onPickEmoji'), onPickEmoji: action('onPickEmoji'),
large: boolean('large', overrideProps.large || false), large: boolean('large', overrideProps.large || false),
scrollToBottom: action('scrollToBottom'),
sortedGroupMembers: overrideProps.sortedGroupMembers || [], sortedGroupMembers: overrideProps.sortedGroupMembers || [],
skinTone: select( skinTone: select(
'skinTone', 'skinTone',

View file

@ -61,6 +61,7 @@ export type InputApi = {
export type Props = { export type Props = {
readonly i18n: LocalizerType; readonly i18n: LocalizerType;
readonly conversationId: string;
readonly disabled?: boolean; readonly disabled?: boolean;
readonly large?: boolean; readonly large?: boolean;
readonly inputApi?: React.MutableRefObject<InputApi | undefined>; readonly inputApi?: React.MutableRefObject<InputApi | undefined>;
@ -84,6 +85,7 @@ export type Props = {
): unknown; ): unknown;
getQuotedMessage(): unknown; getQuotedMessage(): unknown;
clearQuotedMessage(): unknown; clearQuotedMessage(): unknown;
scrollToBottom: (converstionId: string) => unknown;
}; };
const MAX_LENGTH = 64 * 1024; const MAX_LENGTH = 64 * 1024;
@ -92,6 +94,7 @@ const BASE_CLASS_NAME = 'module-composition-input';
export function CompositionInput(props: Props): React.ReactElement { export function CompositionInput(props: Props): React.ReactElement {
const { const {
i18n, i18n,
conversationId,
disabled, disabled,
large, large,
inputApi, inputApi,
@ -103,6 +106,7 @@ export function CompositionInput(props: Props): React.ReactElement {
draftBodyRanges, draftBodyRanges,
getQuotedMessage, getQuotedMessage,
clearQuotedMessage, clearQuotedMessage,
scrollToBottom,
sortedGroupMembers, sortedGroupMembers,
} = props; } = props;
@ -238,6 +242,7 @@ export function CompositionInput(props: Props): React.ReactElement {
`CompositionInput: Submitting message ${timestamp} with ${mentions.length} mentions` `CompositionInput: Submitting message ${timestamp} with ${mentions.length} mentions`
); );
onSubmit(text, mentions, timestamp); onSubmit(text, mentions, timestamp);
scrollToBottom(conversationId);
}; };
if (inputApi) { if (inputApi) {

View file

@ -42,6 +42,7 @@ const candidateConversations = Array.from(Array(100), () =>
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
attachments: overrideProps.attachments, attachments: overrideProps.attachments,
conversationId: 'conversation-id',
candidateConversations, candidateConversations,
doForwardMessage: action('doForwardMessage'), doForwardMessage: action('doForwardMessage'),
i18n, i18n,

View file

@ -40,6 +40,7 @@ import { useAnimated } from '../hooks/useAnimated';
export type DataPropsType = { export type DataPropsType = {
attachments?: Array<AttachmentType>; attachments?: Array<AttachmentType>;
candidateConversations: ReadonlyArray<ConversationType>; candidateConversations: ReadonlyArray<ConversationType>;
conversationId: string;
doForwardMessage: ( doForwardMessage: (
selectedContacts: Array<string>, selectedContacts: Array<string>,
messageBody?: string, messageBody?: string,
@ -74,6 +75,7 @@ const MAX_FORWARD = 5;
export const ForwardMessageModal: FunctionComponent<PropsType> = ({ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
attachments, attachments,
candidateConversations, candidateConversations,
conversationId,
doForwardMessage, doForwardMessage,
i18n, i18n,
isSticker, isSticker,
@ -181,10 +183,10 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
}, [candidateConversations]); }, [candidateConversations]);
const toggleSelectedConversation = useCallback( const toggleSelectedConversation = useCallback(
(conversationId: string) => { (selectedConversationId: string) => {
let removeContact = false; let removeContact = false;
const nextSelectedContacts = selectedContacts.filter(contact => { const nextSelectedContacts = selectedContacts.filter(contact => {
if (contact.id === conversationId) { if (contact.id === selectedConversationId) {
removeContact = true; removeContact = true;
return false; return false;
} }
@ -194,7 +196,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
setSelectedContacts(nextSelectedContacts); setSelectedContacts(nextSelectedContacts);
return; return;
} }
const selectedContact = contactLookup.get(conversationId); const selectedContact = contactLookup.get(selectedConversationId);
if (selectedContact) { if (selectedContact) {
if (selectedContact.announcementsOnly && !selectedContact.areWeAdmin) { if (selectedContact.announcementsOnly && !selectedContact.areWeAdmin) {
setCannotMessage(true); setCannotMessage(true);
@ -330,6 +332,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
) : null} ) : null}
<div className="module-ForwardMessageModal__text-edit-area"> <div className="module-ForwardMessageModal__text-edit-area">
<CompositionInput <CompositionInput
conversationId={conversationId}
clearQuotedMessage={shouldNeverBeCalled} clearQuotedMessage={shouldNeverBeCalled}
draftText={messageBodyText} draftText={messageBodyText}
getQuotedMessage={noop} getQuotedMessage={noop}
@ -337,6 +340,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
inputApi={inputApiRef} inputApi={inputApiRef}
large large
moduleClassName="module-ForwardMessageModal__input" moduleClassName="module-ForwardMessageModal__input"
scrollToBottom={noop}
onEditorStateChange={( onEditorStateChange={(
messageText, messageText,
bodyRanges, bodyRanges,
@ -391,7 +395,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
i18n={i18n} i18n={i18n}
onClickArchiveButton={shouldNeverBeCalled} onClickArchiveButton={shouldNeverBeCalled}
onClickContactCheckbox={( onClickContactCheckbox={(
conversationId: string, selectedConversationId: string,
disabledReason: disabledReason:
| undefined | undefined
| ContactCheckboxDisabledReason | ContactCheckboxDisabledReason
@ -400,7 +404,9 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
disabledReason !== disabledReason !==
ContactCheckboxDisabledReason.MaximumContactsSelected ContactCheckboxDisabledReason.MaximumContactsSelected
) { ) {
toggleSelectedConversation(conversationId); toggleSelectedConversation(
selectedConversationId
);
} }
}} }}
onSelectConversation={shouldNeverBeCalled} onSelectConversation={shouldNeverBeCalled}

View file

@ -466,7 +466,10 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
overrideProps.isLoadingMessages === false overrideProps.isLoadingMessages === false
), ),
items: overrideProps.items || Object.keys(items), items: overrideProps.items || Object.keys(items),
loadCountdownStart: undefined,
messageHeightChangeIndex: undefined,
resetCounter: 0, resetCounter: 0,
scrollToBottomCounter: 0,
scrollToIndex: overrideProps.scrollToIndex, scrollToIndex: overrideProps.scrollToIndex,
scrollToIndexCounter: 0, scrollToIndexCounter: 0,
totalUnread: number('totalUnread', overrideProps.totalUnread || 0), totalUnread: number('totalUnread', overrideProps.totalUnread || 0),
@ -478,6 +481,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
warning: overrideProps.warning, warning: overrideProps.warning,
id: uuid(), id: uuid(),
isNearBottom: false,
renderItem, renderItem,
renderLastSeenIndicator, renderLastSeenIndicator,
renderHeroRow, renderHeroRow,

View file

@ -77,13 +77,14 @@ export type PropsDataType = {
haveNewest: boolean; haveNewest: boolean;
haveOldest: boolean; haveOldest: boolean;
isLoadingMessages: boolean; isLoadingMessages: boolean;
isNearBottom?: boolean; isNearBottom: boolean;
items: ReadonlyArray<string>; items: ReadonlyArray<string>;
loadCountdownStart?: number; loadCountdownStart: number | undefined;
messageHeightChangeIndex?: number; messageHeightChangeIndex: number | undefined;
oldestUnreadIndex?: number; oldestUnreadIndex: number | undefined;
resetCounter: number; resetCounter: number;
scrollToIndex?: number; scrollToBottomCounter: number;
scrollToIndex: number | undefined;
scrollToIndexCounter: number; scrollToIndexCounter: number;
totalUnread: number; totalUnread: number;
}; };
@ -959,7 +960,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
this.scrollDown(false); this.scrollDown(false);
}; };
public scrollDown = (setFocus?: boolean): void => { public scrollDown = (setFocus?: boolean, forceScrollDown?: boolean): void => {
const { const {
haveNewest, haveNewest,
id, id,
@ -976,7 +977,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
const lastId = items[items.length - 1]; const lastId = items[items.length - 1];
const lastSeenIndicatorRow = this.getLastSeenIndicatorRow(); const lastSeenIndicatorRow = this.getLastSeenIndicatorRow();
if (!this.visibleRows) { if (!this.visibleRows || forceScrollDown) {
if (haveNewest) { if (haveNewest) {
this.scrollToBottom(setFocus); this.scrollToBottom(setFocus);
} else if (!isLoadingMessages) { } else if (!isLoadingMessages) {
@ -1033,6 +1034,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
messageHeightChangeIndex, messageHeightChangeIndex,
oldestUnreadIndex, oldestUnreadIndex,
resetCounter, resetCounter,
scrollToBottomCounter,
scrollToIndex, scrollToIndex,
typingContact, typingContact,
} = this.props; } = this.props;
@ -1050,6 +1052,10 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
this.resizeHeroRow(); this.resizeHeroRow();
} }
if (scrollToBottomCounter !== prevProps.scrollToBottomCounter) {
this.scrollDown(false, true);
}
// There are a number of situations which can necessitate that we forget about row // There are a number of situations which can necessitate that we forget about row
// heights previously calculated. We reset the minimum number of rows to minimize // heights previously calculated. We reset the minimum number of rows to minimize
// unexpected changes to the scroll position. Those changes happen because // unexpected changes to the scroll position. Those changes happen because

View file

@ -245,6 +245,7 @@ export type ConversationMessageType = {
messageIds: Array<string>; messageIds: Array<string>;
metrics: MessageMetricsType; metrics: MessageMetricsType;
resetCounter: number; resetCounter: number;
scrollToBottomCounter: number;
scrollToMessageId?: string; scrollToMessageId?: string;
scrollToMessageCounter: number; scrollToMessageCounter: number;
}; };
@ -592,6 +593,12 @@ export type SetSelectedConversationPanelDepthActionType = {
type: 'SET_SELECTED_CONVERSATION_PANEL_DEPTH'; type: 'SET_SELECTED_CONVERSATION_PANEL_DEPTH';
payload: { panelDepth: number }; payload: { panelDepth: number };
}; };
export type ScrollToBpttomActionType = {
type: 'SCROLL_TO_BOTTOM';
payload: {
conversationId: string;
};
};
export type ScrollToMessageActionType = { export type ScrollToMessageActionType = {
type: 'SCROLL_TO_MESSAGE'; type: 'SCROLL_TO_MESSAGE';
payload: { payload: {
@ -741,6 +748,7 @@ export type ConversationActionType =
| ReplaceAvatarsActionType | ReplaceAvatarsActionType
| ReviewGroupMemberNameCollisionActionType | ReviewGroupMemberNameCollisionActionType
| ReviewMessageRequestNameCollisionActionType | ReviewMessageRequestNameCollisionActionType
| ScrollToBpttomActionType
| ScrollToMessageActionType | ScrollToMessageActionType
| SelectedConversationChangedActionType | SelectedConversationChangedActionType
| SetComposeGroupAvatarActionType | SetComposeGroupAvatarActionType
@ -810,6 +818,7 @@ export const actions = {
reviewMessageRequestNameCollision, reviewMessageRequestNameCollision,
saveAvatarToDisk, saveAvatarToDisk,
saveUsername, saveUsername,
scrollToBottom,
scrollToMessage, scrollToMessage,
selectMessage, selectMessage,
setComposeGroupAvatar, setComposeGroupAvatar,
@ -1685,6 +1694,15 @@ function closeMaximumGroupSizeModal(): CloseMaximumGroupSizeModalActionType {
function closeRecommendedGroupSizeModal(): CloseRecommendedGroupSizeModalActionType { function closeRecommendedGroupSizeModal(): CloseRecommendedGroupSizeModalActionType {
return { type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL' }; return { type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL' };
} }
function scrollToBottom(conversationId: string): ScrollToBpttomActionType {
return {
type: 'SCROLL_TO_BOTTOM',
payload: {
conversationId,
},
};
}
function scrollToMessage( function scrollToMessage(
conversationId: string, conversationId: string,
messageId: string messageId: string
@ -2454,6 +2472,9 @@ export function reducer(
scrollToMessageCounter: existingConversation scrollToMessageCounter: existingConversation
? existingConversation.scrollToMessageCounter + 1 ? existingConversation.scrollToMessageCounter + 1
: 0, : 0,
scrollToBottomCounter: existingConversation
? existingConversation.scrollToBottomCounter + 1
: 0,
messageIds, messageIds,
metrics: { metrics: {
...metrics, ...metrics,
@ -2533,6 +2554,28 @@ export function reducer(
}, },
}; };
} }
if (action.type === 'SCROLL_TO_BOTTOM') {
const { payload } = action;
const { conversationId } = payload;
const { messagesByConversation } = state;
const existingConversation = messagesByConversation[conversationId];
if (!existingConversation) {
return state;
}
return {
...state,
messagesByConversation: {
...messagesByConversation,
[conversationId]: {
...existingConversation,
scrollToBottomCounter: existingConversation.scrollToBottomCounter + 1,
},
},
};
}
if (action.type === 'SCROLL_TO_MESSAGE') { if (action.type === 'SCROLL_TO_MESSAGE') {
const { payload } = action; const { payload } = action;
const { conversationId, messageId } = payload; const { conversationId, messageId } = payload;

View file

@ -824,6 +824,7 @@ export function _conversationMessagesSelector(
messageIds, messageIds,
metrics, metrics,
resetCounter, resetCounter,
scrollToBottomCounter,
scrollToMessageId, scrollToMessageId,
scrollToMessageCounter, scrollToMessageCounter,
} = conversation; } = conversation;
@ -862,7 +863,7 @@ export function _conversationMessagesSelector(
isLoadingMessages, isLoadingMessages,
loadCountdownStart, loadCountdownStart,
items, items,
isNearBottom, isNearBottom: isNearBottom || false,
messageHeightChangeIndex: messageHeightChangeIndex:
isNumber(messageHeightChangeIndex) && messageHeightChangeIndex >= 0 isNumber(messageHeightChangeIndex) && messageHeightChangeIndex >= 0
? messageHeightChangeIndex ? messageHeightChangeIndex
@ -872,6 +873,7 @@ export function _conversationMessagesSelector(
? oldestUnreadIndex ? oldestUnreadIndex
: undefined, : undefined,
resetCounter, resetCounter,
scrollToBottomCounter,
scrollToIndex: scrollToIndex:
isNumber(scrollToIndex) && scrollToIndex >= 0 ? scrollToIndex : undefined, isNumber(scrollToIndex) && scrollToIndex >= 0 ? scrollToIndex : undefined,
scrollToIndexCounter: scrollToMessageCounter, scrollToIndexCounter: scrollToMessageCounter,
@ -907,10 +909,16 @@ export const getConversationMessagesSelector = createSelector(
haveNewest: false, haveNewest: false,
haveOldest: false, haveOldest: false,
isLoadingMessages: false, isLoadingMessages: false,
isNearBottom: false,
items: [],
loadCountdownStart: undefined,
messageHeightChangeIndex: undefined,
oldestUnreadIndex: undefined,
resetCounter: 0, resetCounter: 0,
scrollToBottomCounter: 0,
scrollToIndex: undefined,
scrollToIndexCounter: 0, scrollToIndexCounter: 0,
totalUnread: 0, totalUnread: 0,
items: [],
}; };
} }

View file

@ -17,6 +17,7 @@ import type { AttachmentType } from '../../types/Attachment';
export type SmartForwardMessageModalProps = { export type SmartForwardMessageModalProps = {
attachments?: Array<AttachmentType>; attachments?: Array<AttachmentType>;
conversationId: string;
doForwardMessage: ( doForwardMessage: (
selectedContacts: Array<string>, selectedContacts: Array<string>,
messageBody?: string, messageBody?: string,
@ -40,6 +41,7 @@ const mapStateToProps = (
): DataPropsType => { ): DataPropsType => {
const { const {
attachments, attachments,
conversationId,
doForwardMessage, doForwardMessage,
isSticker, isSticker,
messageBody, messageBody,
@ -56,6 +58,7 @@ const mapStateToProps = (
return { return {
attachments, attachments,
candidateConversations, candidateConversations,
conversationId,
doForwardMessage, doForwardMessage,
i18n: getIntl(state), i18n: getIntl(state),
isSticker, isSticker,

View file

@ -337,6 +337,7 @@ describe('both/state/ducks/conversations', () => {
totalUnread: 0, totalUnread: 0,
}, },
resetCounter: 0, resetCounter: 0,
scrollToBottomCounter: 0,
scrollToMessageCounter: 0, scrollToMessageCounter: 0,
}; };
} }
@ -839,6 +840,7 @@ describe('both/state/ducks/conversations', () => {
messageIds: [messageId], messageIds: [messageId],
metrics: { totalUnread: 0 }, metrics: { totalUnread: 0 },
resetCounter: 0, resetCounter: 0,
scrollToBottomCounter: 0,
scrollToMessageCounter: 0, scrollToMessageCounter: 0,
}, },
}, },

View file

@ -1654,6 +1654,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
window.reduxStore, window.reduxStore,
{ {
attachments, attachments,
conversationId: this.model.id,
doForwardMessage: async ( doForwardMessage: async (
conversationIds: Array<string>, conversationIds: Array<string>,
messageBody?: string, messageBody?: string,