On conversation open, scroll to unread indicator if present

This commit is contained in:
Evan Hahn 2022-03-08 14:05:05 -06:00 committed by GitHub
parent efee887135
commit 944d60f40b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 49 additions and 67 deletions

View file

@ -1,7 +1,7 @@
// Copyright 2019-2020 Signal Messenger, LLC // Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React, { forwardRef } from 'react';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
@ -10,16 +10,18 @@ export type Props = {
i18n: LocalizerType; i18n: LocalizerType;
}; };
export const LastSeenIndicator = ({ count, i18n }: Props): JSX.Element => { export const LastSeenIndicator = forwardRef<HTMLDivElement, Props>(
const message = ({ count, i18n }, ref) => {
count === 1 const message =
? i18n('unreadMessage') count === 1
: i18n('unreadMessages', [String(count)]); ? i18n('unreadMessage')
: i18n('unreadMessages', [String(count)]);
return ( return (
<div className="module-last-seen-indicator"> <div className="module-last-seen-indicator" ref={ref}>
<div className="module-last-seen-indicator__bar" /> <div className="module-last-seen-indicator__bar" />
<div className="module-last-seen-indicator__text">{message}</div> <div className="module-last-seen-indicator__text">{message}</div>
</div> </div>
); );
}; }
);

View file

@ -19,7 +19,6 @@ import { StorybookThemeContext } from '../../../.storybook/StorybookThemeContext
import { ConversationHero } from './ConversationHero'; import { ConversationHero } from './ConversationHero';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { getRandomColor } from '../../test-both/helpers/getRandomColor'; import { getRandomColor } from '../../test-both/helpers/getRandomColor';
import { LastSeenIndicator } from './LastSeenIndicator';
import { TypingBubble } from './TypingBubble'; import { TypingBubble } from './TypingBubble';
import { ContactSpoofingType } from '../../util/contactSpoofing'; import { ContactSpoofingType } from '../../util/contactSpoofing';
import { ReadStatus } from '../../messages/MessageReadStatus'; import { ReadStatus } from '../../messages/MessageReadStatus';
@ -445,10 +444,6 @@ const renderItem = ({
/> />
); );
const renderLastSeenIndicator = () => (
<LastSeenIndicator count={2} i18n={i18n} />
);
const getAbout = () => text('about', '👍 Free to chat'); const getAbout = () => text('about', '👍 Free to chat');
const getTitle = () => text('name', 'Cayce Bollard'); const getTitle = () => text('name', 'Cayce Bollard');
const getName = () => text('name', 'Cayce Bollard'); const getName = () => text('name', 'Cayce Bollard');
@ -528,7 +523,6 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
id: uuid(), id: uuid(),
renderItem, renderItem,
renderLastSeenIndicator,
renderHeroRow, renderHeroRow,
renderTypingBubble, renderTypingBubble,
typingContactId: overrideProps.typingContactId, typingContactId: overrideProps.typingContactId,

View file

@ -4,7 +4,7 @@
import { first, get, isNumber, last, pick, throttle } from 'lodash'; import { first, get, isNumber, last, pick, throttle } from 'lodash';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactChild, ReactNode, RefObject } from 'react'; import type { ReactChild, ReactNode, RefObject } from 'react';
import React, { Fragment } from 'react'; import React from 'react';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import Measure from 'react-measure'; import Measure from 'react-measure';
@ -41,6 +41,7 @@ import {
scrollToBottom, scrollToBottom,
setScrollBottom, setScrollBottom,
} from '../../util/scrollUtil'; } from '../../util/scrollUtil';
import { LastSeenIndicator } from './LastSeenIndicator';
const AT_BOTTOM_THRESHOLD = 15; const AT_BOTTOM_THRESHOLD = 15;
const MIN_ROW_HEIGHT = 18; const MIN_ROW_HEIGHT = 18;
@ -120,7 +121,6 @@ type PropsHousekeepingType = {
previousMessageId: undefined | string; previousMessageId: undefined | string;
unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement; unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
}) => JSX.Element; }) => JSX.Element;
renderLastSeenIndicator: (id: string) => JSX.Element;
renderHeroRow: ( renderHeroRow: (
id: string, id: string,
unblurAvatar: () => void, unblurAvatar: () => void,
@ -177,8 +177,11 @@ type StateType = {
widthBreakpoint: WidthBreakpoint; widthBreakpoint: WidthBreakpoint;
}; };
const scrollToUnreadIndicator = Symbol('scrollToUnreadIndicator');
type SnapshotType = type SnapshotType =
| null | null
| typeof scrollToUnreadIndicator
| { scrollToIndex: number } | { scrollToIndex: number }
| { scrollTop: number } | { scrollTop: number }
| { scrollBottom: number }; | { scrollBottom: number };
@ -258,6 +261,7 @@ export class Timeline extends React.Component<
> { > {
private readonly containerRef = React.createRef<HTMLDivElement>(); private readonly containerRef = React.createRef<HTMLDivElement>();
private readonly messagesRef = React.createRef<HTMLDivElement>(); private readonly messagesRef = React.createRef<HTMLDivElement>();
private readonly lastSeenIndicatorRef = React.createRef<HTMLDivElement>();
private intersectionObserver?: IntersectionObserver; private intersectionObserver?: IntersectionObserver;
private messagesResizeObserver?: ResizeObserver; private messagesResizeObserver?: ResizeObserver;
@ -538,6 +542,7 @@ export class Timeline extends React.Component<
isIncomingMessageRequest, isIncomingMessageRequest,
isLoadingMessages, isLoadingMessages,
items: newItems, items: newItems,
oldestUnreadIndex,
scrollToIndex, scrollToIndex,
scrollToIndexCounter: newScrollToIndexCounter, scrollToIndexCounter: newScrollToIndexCounter,
typingContactId, typingContactId,
@ -560,7 +565,13 @@ export class Timeline extends React.Component<
} }
if (justFinishedInitialLoad) { if (justFinishedInitialLoad) {
return isIncomingMessageRequest ? { scrollTop: 0 } : { scrollBottom: 0 }; if (isIncomingMessageRequest) {
return { scrollTop: 0 };
}
if (isNumber(oldestUnreadIndex)) {
return scrollToUnreadIndicator;
}
return { scrollBottom: 0 };
} }
if ( if (
@ -599,7 +610,18 @@ export class Timeline extends React.Component<
const containerEl = this.containerRef.current; const containerEl = this.containerRef.current;
if (containerEl && snapshot) { if (containerEl && snapshot) {
if ('scrollToIndex' in snapshot) { if (snapshot === scrollToUnreadIndicator) {
const lastSeenIndicatorEl = this.lastSeenIndicatorRef.current;
if (lastSeenIndicatorEl) {
lastSeenIndicatorEl.scrollIntoView();
} else {
scrollToBottom(containerEl);
assert(
false,
'<Timeline> expected a last seen indicator but it was not found'
);
}
} else if ('scrollToIndex' in snapshot) {
this.scrollToItemIndex(snapshot.scrollToIndex); this.scrollToItemIndex(snapshot.scrollToIndex);
} else if ('scrollTop' in snapshot) { } else if ('scrollTop' in snapshot) {
containerEl.scrollTop = snapshot.scrollTop; containerEl.scrollTop = snapshot.scrollTop;
@ -746,12 +768,12 @@ export class Timeline extends React.Component<
removeMember, removeMember,
renderHeroRow, renderHeroRow,
renderItem, renderItem,
renderLastSeenIndicator,
renderTypingBubble, renderTypingBubble,
reviewGroupMemberNameCollision, reviewGroupMemberNameCollision,
reviewMessageRequestNameCollision, reviewMessageRequestNameCollision,
showContactModal, showContactModal,
theme, theme,
totalUnread,
typingContactId, typingContactId,
unblurAvatar, unblurAvatar,
unreadCount, unreadCount,
@ -848,7 +870,12 @@ export class Timeline extends React.Component<
if (oldestUnreadIndex === itemIndex) { if (oldestUnreadIndex === itemIndex) {
unreadIndicatorPlacement = UnreadIndicatorPlacement.JustAbove; unreadIndicatorPlacement = UnreadIndicatorPlacement.JustAbove;
messageNodes.push( messageNodes.push(
<Fragment key="unread">{renderLastSeenIndicator(id)}</Fragment> <LastSeenIndicator
key="last seen indicator"
count={totalUnread}
i18n={i18n}
ref={this.lastSeenIndicatorRef}
/>
); );
} else if (oldestUnreadIndex === nextItemIndex) { } else if (oldestUnreadIndex === nextItemIndex) {
unreadIndicatorPlacement = UnreadIndicatorPlacement.JustBelow; unreadIndicatorPlacement = UnreadIndicatorPlacement.JustBelow;

View file

@ -1,35 +0,0 @@
// Copyright 2019-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { LastSeenIndicator } from '../../components/conversation/LastSeenIndicator';
import type { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { getConversationMessagesSelector } from '../selectors/conversations';
type ExternalProps = {
id: string;
};
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id } = props;
const conversation = getConversationMessagesSelector(state)(id);
if (!conversation) {
throw new Error(`Did not find conversation ${id} in state!`);
}
const { totalUnread } = conversation;
return {
count: totalUnread,
i18n: getIntl(state),
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartLastSeenIndicator = smart(LastSeenIndicator);

View file

@ -30,7 +30,6 @@ import {
import { SmartTimelineItem } from './TimelineItem'; import { SmartTimelineItem } from './TimelineItem';
import { SmartTypingBubble } from './TypingBubble'; import { SmartTypingBubble } from './TypingBubble';
import { SmartLastSeenIndicator } from './LastSeenIndicator';
import { SmartHeroRow } from './HeroRow'; import { SmartHeroRow } from './HeroRow';
import { renderAudioAttachment } from './renderAudioAttachment'; import { renderAudioAttachment } from './renderAudioAttachment';
import { renderEmojiPicker } from './renderEmojiPicker'; import { renderEmojiPicker } from './renderEmojiPicker';
@ -139,10 +138,6 @@ function renderItem({
); );
} }
function renderLastSeenIndicator(id: string): JSX.Element {
return <SmartLastSeenIndicator id={id} />;
}
function renderHeroRow( function renderHeroRow(
id: string, id: string,
unblurAvatar: () => void, unblurAvatar: () => void,
@ -313,7 +308,6 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
i18n: getIntl(state), i18n: getIntl(state),
theme: getTheme(state), theme: getTheme(state),
renderItem, renderItem,
renderLastSeenIndicator,
renderHeroRow, renderHeroRow,
renderTypingBubble, renderTypingBubble,
...actions, ...actions,