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,
clearQuotedMessage: action('clearQuotedMessage'),
getQuotedMessage: action('getQuotedMessage'),
scrollToBottom: action('scrollToBottom'),
sortedGroupMembers: [],
// EmojiButton
onPickEmoji: action('onPickEmoji'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -77,13 +77,14 @@ export type PropsDataType = {
haveNewest: boolean;
haveOldest: boolean;
isLoadingMessages: boolean;
isNearBottom?: boolean;
isNearBottom: boolean;
items: ReadonlyArray<string>;
loadCountdownStart?: number;
messageHeightChangeIndex?: number;
oldestUnreadIndex?: number;
loadCountdownStart: number | undefined;
messageHeightChangeIndex: number | undefined;
oldestUnreadIndex: number | undefined;
resetCounter: number;
scrollToIndex?: number;
scrollToBottomCounter: number;
scrollToIndex: number | undefined;
scrollToIndexCounter: number;
totalUnread: number;
};
@ -959,7 +960,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
this.scrollDown(false);
};
public scrollDown = (setFocus?: boolean): void => {
public scrollDown = (setFocus?: boolean, forceScrollDown?: boolean): void => {
const {
haveNewest,
id,
@ -976,7 +977,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
const lastId = items[items.length - 1];
const lastSeenIndicatorRow = this.getLastSeenIndicatorRow();
if (!this.visibleRows) {
if (!this.visibleRows || forceScrollDown) {
if (haveNewest) {
this.scrollToBottom(setFocus);
} else if (!isLoadingMessages) {
@ -1033,6 +1034,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
messageHeightChangeIndex,
oldestUnreadIndex,
resetCounter,
scrollToBottomCounter,
scrollToIndex,
typingContact,
} = this.props;
@ -1050,6 +1052,10 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
this.resizeHeroRow();
}
if (scrollToBottomCounter !== prevProps.scrollToBottomCounter) {
this.scrollDown(false, true);
}
// 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
// unexpected changes to the scroll position. Those changes happen because

View File

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

View File

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

View File

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

View File

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

View File

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