On conversation open, scroll to unread indicator if present
This commit is contained in:
parent
efee887135
commit
944d60f40b
5 changed files with 49 additions and 67 deletions
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
|
|
@ -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,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue