Keep reaction poppers visible at all times

This commit is contained in:
Evan Hahn 2021-08-20 14:36:27 -05:00 committed by GitHub
parent f11c366f53
commit 70d059beeb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 94 additions and 14 deletions

View file

@ -84,6 +84,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
checkForAccount: action('checkForAccount'), checkForAccount: action('checkForAccount'),
clearSelectedMessage: action('clearSelectedMessage'), clearSelectedMessage: action('clearSelectedMessage'),
collapseMetadata: overrideProps.collapseMetadata, collapseMetadata: overrideProps.collapseMetadata,
containerElementRef: React.createRef<HTMLElement>(),
conversationColor: conversationColor:
overrideProps.conversationColor || overrideProps.conversationColor ||
select('conversationColor', ConversationColors, ConversationColors[0]), select('conversationColor', ConversationColors, ConversationColors[0]),

View file

@ -1,12 +1,13 @@
// Copyright 2018-2021 Signal Messenger, LLC // Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React, { RefObject } from 'react';
import ReactDOM, { createPortal } from 'react-dom'; import ReactDOM, { createPortal } from 'react-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import { drop, groupBy, orderBy, take, unescape } from 'lodash'; import { drop, groupBy, orderBy, take, unescape } from 'lodash';
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu'; import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
import { Manager, Popper, Reference } from 'react-popper'; import { Manager, Popper, Reference } from 'react-popper';
import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow';
import { import {
ConversationType, ConversationType,
@ -188,6 +189,7 @@ export type PropsData = {
}; };
export type PropsHousekeeping = { export type PropsHousekeeping = {
containerElementRef: RefObject<HTMLElement>;
i18n: LocalizerType; i18n: LocalizerType;
interactionMode: InteractionModeType; interactionMode: InteractionModeType;
theme?: ThemeType; theme?: ThemeType;
@ -1443,7 +1445,13 @@ export class Message extends React.PureComponent<Props, State> {
{reactionPickerRoot && {reactionPickerRoot &&
createPortal( createPortal(
// eslint-disable-next-line consistent-return // eslint-disable-next-line consistent-return
<Popper placement="top" modifiers={[offsetDistanceModifier(4)]}> <Popper
placement="top"
modifiers={[
offsetDistanceModifier(4),
this.popperPreventOverflowModifier(),
]}
>
{({ ref, style }) => ( {({ ref, style }) => (
<SmartReactionPicker <SmartReactionPicker
ref={ref} ref={ref}
@ -1801,6 +1809,23 @@ export class Message extends React.PureComponent<Props, State> {
); );
} }
private popperPreventOverflowModifier(): Partial<PreventOverflowModifier> {
const { containerElementRef } = this.props;
return {
name: 'preventOverflow',
options: {
altAxis: true,
boundary: containerElementRef.current || undefined,
padding: {
bottom: 16,
left: 8,
right: 8,
top: 16,
},
},
};
}
public toggleReactionViewer = (onlyRemove = false): void => { public toggleReactionViewer = (onlyRemove = false): void => {
this.setState(({ reactionViewerRoot }) => { this.setState(({ reactionViewerRoot }) => {
if (reactionViewerRoot) { if (reactionViewerRoot) {
@ -2022,7 +2047,11 @@ export class Message extends React.PureComponent<Props, State> {
</Reference> </Reference>
{reactionViewerRoot && {reactionViewerRoot &&
createPortal( createPortal(
<Popper placement={popperPlacement}> <Popper
placement={popperPlacement}
strategy="fixed"
modifiers={[this.popperPreventOverflowModifier()]}
>
{({ ref, style }) => ( {({ ref, style }) => (
<ReactionViewer <ReactionViewer
ref={ref} ref={ref}

View file

@ -94,6 +94,8 @@ const _keyForError = (error: Error): string => {
export class MessageDetail extends React.Component<Props> { export class MessageDetail extends React.Component<Props> {
private readonly focusRef = React.createRef<HTMLDivElement>(); private readonly focusRef = React.createRef<HTMLDivElement>();
private readonly messageContainerRef = React.createRef<HTMLDivElement>();
public componentDidMount(): void { public componentDidMount(): void {
// When this component is created, it's initially not part of the DOM, and then it's // When this component is created, it's initially not part of the DOM, and then it's
// added off-screen and animated in. This ensures that the focus takes. // added off-screen and animated in. This ensures that the focus takes.
@ -289,13 +291,17 @@ export class MessageDetail extends React.Component<Props> {
return ( return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
<div className="module-message-detail" tabIndex={0} ref={this.focusRef}> <div className="module-message-detail" tabIndex={0} ref={this.focusRef}>
<div className="module-message-detail__message-container"> <div
className="module-message-detail__message-container"
ref={this.messageContainerRef}
>
<Message <Message
{...message} {...message}
renderingContext="conversation/MessageDetail" renderingContext="conversation/MessageDetail"
checkForAccount={checkForAccount} checkForAccount={checkForAccount}
clearSelectedMessage={clearSelectedMessage} clearSelectedMessage={clearSelectedMessage}
contactNameColor={contactNameColor} contactNameColor={contactNameColor}
containerElementRef={this.messageContainerRef}
deleteMessage={deleteMessage} deleteMessage={deleteMessage}
deleteMessageForEveryone={deleteMessageForEveryone} deleteMessageForEveryone={deleteMessageForEveryone}
disableMenu disableMenu

View file

@ -38,6 +38,7 @@ const defaultMessageProps: MessagesProps = {
canDownload: true, canDownload: true,
checkForAccount: action('checkForAccount'), checkForAccount: action('checkForAccount'),
clearSelectedMessage: action('default--clearSelectedMessage'), clearSelectedMessage: action('default--clearSelectedMessage'),
containerElementRef: React.createRef<HTMLElement>(),
conversationColor: 'crimson', conversationColor: 'crimson',
conversationId: 'conversationId', conversationId: 'conversationId',
conversationType: 'direct', // override conversationType: 'direct', // override

View file

@ -376,7 +376,13 @@ const actions = () => ({
unblurAvatar: action('unblurAvatar'), unblurAvatar: action('unblurAvatar'),
}); });
const renderItem = (id: string) => ( const renderItem = (
id: string,
_conversationId: unknown,
_onHeightChange: unknown,
_actionProps: unknown,
containerElementRef: React.RefObject<HTMLElement>
) => (
<TimelineItem <TimelineItem
id="" id=""
isSelected={false} isSelected={false}
@ -384,6 +390,7 @@ const renderItem = (id: string) => (
item={items[id]} item={items[id]}
i18n={i18n} i18n={i18n}
interactionMode="keyboard" interactionMode="keyboard"
containerElementRef={containerElementRef}
conversationId="" conversationId=""
renderContact={() => '*ContactName*'} renderContact={() => '*ContactName*'}
renderUniversalTimerNotification={() => ( renderUniversalTimerNotification={() => (

View file

@ -3,7 +3,7 @@
import { debounce, get, isNumber, pick, identity } from 'lodash'; import { debounce, get, isNumber, pick, identity } from 'lodash';
import classNames from 'classnames'; import classNames from 'classnames';
import React, { CSSProperties, ReactChild, ReactNode } from 'react'; import React, { CSSProperties, ReactChild, ReactNode, RefObject } from 'react';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { import {
AutoSizer, AutoSizer,
@ -107,7 +107,8 @@ type PropsHousekeepingType = {
id: string, id: string,
conversationId: string, conversationId: string,
onHeightChange: (messageId: string) => unknown, onHeightChange: (messageId: string) => unknown,
actions: PropsActionsType actions: PropsActionsType,
containerElementRef: RefObject<HTMLElement>
) => JSX.Element; ) => JSX.Element;
renderLastSeenIndicator: (id: string) => JSX.Element; renderLastSeenIndicator: (id: string) => JSX.Element;
renderHeroRow: ( renderHeroRow: (
@ -297,7 +298,9 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
public resizeFlag = false; public resizeFlag = false;
public listRef = React.createRef<List>(); private readonly containerRef = React.createRef<HTMLDivElement>();
private readonly listRef = React.createRef<List>();
public visibleRows: VisibleRowsType | undefined; public visibleRows: VisibleRowsType | undefined;
@ -808,7 +811,13 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
role="row" role="row"
> >
<ErrorBoundary i18n={i18n} showDebugLog={() => window.showDebugLog()}> <ErrorBoundary i18n={i18n} showDebugLog={() => window.showDebugLog()}>
{renderItem(messageId, id, this.resizeMessage, actions)} {renderItem(
messageId,
id,
this.resizeMessage,
actions,
this.containerRef
)}
</ErrorBoundary> </ErrorBoundary>
</div> </div>
); );
@ -1502,6 +1511,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
tabIndex={-1} tabIndex={-1}
onBlur={this.handleBlur} onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
ref={this.containerRef}
> >
{timelineWarning} {timelineWarning}

View file

@ -41,6 +41,7 @@ const renderUniversalTimerNotification = () => (
); );
const getDefaultProps = () => ({ const getDefaultProps = () => ({
containerElementRef: React.createRef<HTMLElement>(),
conversationId: 'conversation-id', conversationId: 'conversation-id',
id: 'asdf', id: 'asdf',
isSelected: false, isSelected: false,

View file

@ -1,7 +1,7 @@
// Copyright 2019-2021 Signal Messenger, LLC // Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React, { RefObject } from 'react';
import { omit } from 'lodash'; import { omit } from 'lodash';
import { LocalizerType, ThemeType } from '../../types/Util'; import { LocalizerType, ThemeType } from '../../types/Util';
@ -153,6 +153,7 @@ export type TimelineItemType =
| VerificationNotificationType; | VerificationNotificationType;
type PropsLocalType = { type PropsLocalType = {
containerElementRef: RefObject<HTMLElement>;
conversationId: string; conversationId: string;
item?: TimelineItemType; item?: TimelineItemType;
id: string; id: string;
@ -179,6 +180,7 @@ export type PropsType = PropsLocalType &
export class TimelineItem extends React.PureComponent<PropsType> { export class TimelineItem extends React.PureComponent<PropsType> {
public render(): JSX.Element | null { public render(): JSX.Element | null {
const { const {
containerElementRef,
conversationId, conversationId,
id, id,
isSelected, isSelected,
@ -204,6 +206,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
<Message <Message
{...omit(this.props, ['item'])} {...omit(this.props, ['item'])}
{...item.data} {...item.data}
containerElementRef={containerElementRef}
i18n={i18n} i18n={i18n}
theme={theme} theme={theme}
renderingContext="conversation/TimelineItem" renderingContext="conversation/TimelineItem"

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { isEmpty, mapValues, pick } from 'lodash'; import { isEmpty, mapValues, pick } from 'lodash';
import React from 'react'; import React, { RefObject } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import memoizee from 'memoizee'; import memoizee from 'memoizee';
@ -66,11 +66,13 @@ function renderItem(
messageId: string, messageId: string,
conversationId: string, conversationId: string,
onHeightChange: (messageId: string) => unknown, onHeightChange: (messageId: string) => unknown,
actionProps: TimelineActionsType actionProps: TimelineActionsType,
containerElementRef: RefObject<HTMLElement>
): JSX.Element { ): JSX.Element {
return ( return (
<SmartTimelineItem <SmartTimelineItem
{...actionProps} {...actionProps}
containerElementRef={containerElementRef}
conversationId={conversationId} conversationId={conversationId}
id={messageId} id={messageId}
onHeightChange={createBoundOnHeightChange(onHeightChange, messageId)} onHeightChange={createBoundOnHeightChange(onHeightChange, messageId)}

View file

@ -1,7 +1,7 @@
// Copyright 2019-2021 Signal Messenger, LLC // Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React, { RefObject } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions'; import { mapDispatchToProps } from '../actions';
@ -21,6 +21,7 @@ import { SmartUniversalTimerNotification } from './UniversalTimerNotification';
type ExternalProps = { type ExternalProps = {
id: string; id: string;
conversationId: string; conversationId: string;
containerElementRef: RefObject<HTMLElement>;
}; };
// Workaround: A react component's required properties are filtering up through connect() // Workaround: A react component's required properties are filtering up through connect()
@ -38,7 +39,7 @@ function renderUniversalTimerNotification(): JSX.Element {
} }
const mapStateToProps = (state: StateType, props: ExternalProps) => { const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id, conversationId } = props; const { id, conversationId, containerElementRef } = props;
const messageSelector = getMessageSelector(state); const messageSelector = getMessageSelector(state);
const item = messageSelector(id); const item = messageSelector(id);
@ -51,6 +52,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
return { return {
item, item,
id, id,
containerElementRef,
conversationId, conversationId,
conversationColor: conversation?.conversationColor, conversationColor: conversation?.conversationColor,
customColor: conversation?.customColor, customColor: conversation?.customColor,

View file

@ -13845,6 +13845,14 @@
"updated": "2019-11-01T22:46:33.013Z", "updated": "2019-11-01T22:46:33.013Z",
"reasonDetail": "Used for setting focus only" "reasonDetail": "Used for setting focus only"
}, },
{
"rule": "React-createRef",
"path": "ts/components/conversation/MessageDetail.js",
"line": " this.messageContainerRef = react_1.default.createRef();",
"reasonCategory": "usageTrusted",
"updated": "2021-08-20T16:48:00.885Z",
"reasonDetail": "Needed to confine Poppers. We don't actually manipulate this DOM reference."
},
{ {
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/conversation/Quote.js", "path": "ts/components/conversation/Quote.js",
@ -13869,6 +13877,14 @@
"updated": "2019-07-31T00:19:18.696Z", "updated": "2019-07-31T00:19:18.696Z",
"reasonDetail": "Timeline needs to interact with its child List directly" "reasonDetail": "Timeline needs to interact with its child List directly"
}, },
{
"rule": "React-createRef",
"path": "ts/components/conversation/Timeline.js",
"line": " this.containerRef = react_1.default.createRef();",
"reasonCategory": "usageTrusted",
"updated": "2021-08-20T16:48:00.885Z",
"reasonDetail": "Needed to confine Poppers. We don't actually manipulate this DOM reference."
},
{ {
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.js", "path": "ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.js",

View file

@ -26,6 +26,8 @@ const excludedFilesRegexps = [
// Non-distributed files // Non-distributed files
'\\.d\\.ts$', '\\.d\\.ts$',
'.+\\.stories\\.js',
'.+\\.stories\\.tsx',
// High-traffic files in our project // High-traffic files in our project
'^app/.+(ts|js)', '^app/.+(ts|js)',