Keyboard shortcuts and accessibility

This commit is contained in:
Scott Nonnenberg 2019-11-07 13:36:16 -08:00
parent 8590a047c7
commit 20a892247f
87 changed files with 3652 additions and 711 deletions

View file

@ -40,7 +40,6 @@ interface Trigger {
// Same as MIN_WIDTH in ImageGrid.tsx
const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200;
const STICKER_SIZE = 128;
const SELECTED_TIMEOUT = 1000;
interface LinkPreviewType {
title: string;
@ -52,11 +51,11 @@ interface LinkPreviewType {
export type PropsData = {
id: string;
conversationId: string;
text?: string;
textPending?: boolean;
isSticker: boolean;
isSelected: boolean;
isSelectedCounter: number;
direction: 'incoming' | 'outgoing';
timestamp: number;
status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error';
@ -131,6 +130,7 @@ export type PropsActions = {
sentAt: number;
}
) => void;
selectMessage: (messageId: string, conversationId: string) => unknown;
};
export type Props = PropsData & PropsHousekeeping & PropsActions;
@ -139,58 +139,64 @@ interface State {
expiring: boolean;
expired: boolean;
imageBroken: boolean;
isSelected: boolean;
prevSelectedCounter: number;
}
const EXPIRATION_CHECK_MINIMUM = 2000;
const EXPIRED_DELAY = 600;
export class Message extends React.PureComponent<Props, State> {
public captureMenuTriggerBound: (trigger: any) => void;
public showMenuBound: (event: React.MouseEvent<HTMLDivElement>) => void;
public handleImageErrorBound: () => void;
public menuTriggerRef: Trigger | undefined;
public focusRef: React.RefObject<HTMLDivElement> = React.createRef();
public audioRef: React.RefObject<HTMLAudioElement> = React.createRef();
public state = {
expiring: false,
expired: false,
imageBroken: false,
};
public expirationCheckInterval: any;
public expiredTimeout: any;
public selectedTimeout: any;
public constructor(props: Props) {
super(props);
public captureMenuTrigger = (triggerRef: Trigger) => {
this.menuTriggerRef = triggerRef;
};
this.captureMenuTriggerBound = this.captureMenuTrigger.bind(this);
this.showMenuBound = this.showMenu.bind(this);
this.handleImageErrorBound = this.handleImageError.bind(this);
this.state = {
expiring: false,
expired: false,
imageBroken: false,
isSelected: props.isSelected,
prevSelectedCounter: props.isSelectedCounter,
};
}
public static getDerivedStateFromProps(props: Props, state: State): State {
if (
props.isSelected &&
props.isSelectedCounter !== state.prevSelectedCounter
) {
return {
...state,
isSelected: props.isSelected,
prevSelectedCounter: props.isSelectedCounter,
};
public showMenu = (event: React.MouseEvent<HTMLDivElement>) => {
if (this.menuTriggerRef) {
this.menuTriggerRef.handleContextClick(event);
}
};
return state;
}
public handleImageError = () => {
const { id } = this.props;
// tslint:disable-next-line no-console
console.log(
`Message ${id}: Image failed to load; failing over to placeholder`
);
this.setState({
imageBroken: true,
});
};
public setSelected = () => {
const { id, conversationId, selectMessage } = this.props;
selectMessage(id, conversationId);
};
public setFocus = () => {
if (this.focusRef.current) {
this.focusRef.current.focus();
}
};
public componentDidMount() {
this.startSelectedTimer();
const { isSelected } = this.props;
if (isSelected) {
this.setFocus();
}
const { expirationLength } = this.props;
if (!expirationLength) {
@ -219,27 +225,14 @@ export class Message extends React.PureComponent<Props, State> {
}
}
public componentDidUpdate() {
this.startSelectedTimer();
public componentDidUpdate(prevProps: Props) {
if (!prevProps.isSelected && this.props.isSelected) {
this.setFocus();
}
this.checkExpired();
}
public startSelectedTimer() {
const { isSelected } = this.state;
if (!isSelected) {
return;
}
if (!this.selectedTimeout) {
this.selectedTimeout = setTimeout(() => {
this.selectedTimeout = undefined;
this.setState({ isSelected: false });
this.props.clearSelectedMessage();
}, SELECTED_TIMEOUT);
}
}
public checkExpired() {
const now = Date.now();
const { isExpired, expirationTimestamp, expirationLength } = this.props;
@ -265,14 +258,6 @@ export class Message extends React.PureComponent<Props, State> {
}
}
public handleImageError() {
// tslint:disable-next-line no-console
console.log('Message: Image failed to load; failing over to placeholder');
this.setState({
imageBroken: true,
});
}
public renderMetadata() {
const {
collapseMetadata,
@ -427,7 +412,7 @@ export class Message extends React.PureComponent<Props, State> {
isSticker,
text,
} = this.props;
const { imageBroken, isSelected } = this.state;
const { imageBroken } = this.state;
if (!attachments || !attachments[0]) {
return null;
@ -449,6 +434,8 @@ export class Message extends React.PureComponent<Props, State> {
) {
const prefix = isSticker ? 'sticker' : 'attachment';
const bottomOverlay = !isSticker && !collapseMetadata;
// We only want users to tab into this if there's more than one
const tabIndex = attachments.length > 1 ? 0 : -1;
return (
<div
@ -470,11 +457,11 @@ export class Message extends React.PureComponent<Props, State> {
withContentAbove={isSticker || withContentAbove}
withContentBelow={isSticker || withContentBelow}
isSticker={isSticker}
isSelected={isSticker && isSelected}
stickerSize={STICKER_SIZE}
bottomOverlay={bottomOverlay}
i18n={i18n}
onError={this.handleImageErrorBound}
onError={this.handleImageError}
tabIndex={tabIndex}
onClick={attachment => {
showVisualAttachment({ attachment, messageId: id });
}}
@ -484,6 +471,7 @@ export class Message extends React.PureComponent<Props, State> {
} else if (!firstAttachment.pending && isAudio(attachments)) {
return (
<audio
ref={this.audioRef}
controls={true}
className={classNames(
'module-message__audio-attachment',
@ -505,7 +493,7 @@ export class Message extends React.PureComponent<Props, State> {
const isDangerous = isFileDangerous(fileName || '');
return (
<div
<button
className={classNames(
'module-message__generic-attachment',
withContentBelow
@ -515,6 +503,14 @@ export class Message extends React.PureComponent<Props, State> {
? 'module-message__generic-attachment--with-content-above'
: null
)}
// There's only ever one of these, so we don't want users to tab into it
tabIndex={-1}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
this.openGenericAttachment();
}}
>
{pending ? (
<div className="module-message__generic-attachment__spinner-container">
@ -554,7 +550,7 @@ export class Message extends React.PureComponent<Props, State> {
{fileSize}
</div>
</div>
</div>
</button>
);
}
}
@ -597,15 +593,25 @@ export class Message extends React.PureComponent<Props, State> {
width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH;
return (
<div
role="button"
<button
className={classNames(
'module-message__link-preview',
withContentAbove
? 'module-message__link-preview--with-content-above'
: null
)}
onClick={() => {
onKeyDown={(event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === 'Space') {
event.stopPropagation();
event.preventDefault();
openLink(first.url);
}
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
openLink(first.url);
}}
>
@ -614,7 +620,7 @@ export class Message extends React.PureComponent<Props, State> {
attachments={[first.image]}
withContentAbove={withContentAbove}
withContentBelow={true}
onError={this.handleImageErrorBound}
onError={this.handleImageError}
i18n={i18n}
/>
) : null}
@ -638,7 +644,7 @@ export class Message extends React.PureComponent<Props, State> {
width={72}
url={first.image.url}
attachment={first.image}
onError={this.handleImageErrorBound}
onError={this.handleImageError}
i18n={i18n}
/>
</div>
@ -659,7 +665,7 @@ export class Message extends React.PureComponent<Props, State> {
</div>
</div>
</div>
</div>
</button>
);
}
@ -751,8 +757,7 @@ export class Message extends React.PureComponent<Props, State> {
}
return (
<div
role="button"
<button
onClick={() => {
if (contact.signalAccount) {
openConversation(contact.signalAccount);
@ -761,7 +766,7 @@ export class Message extends React.PureComponent<Props, State> {
className="module-message__send-message-button"
>
{i18n('sendMessageToContact')}
</div>
</button>
);
}
@ -853,35 +858,21 @@ export class Message extends React.PureComponent<Props, State> {
);
}
public captureMenuTrigger(triggerRef: Trigger) {
this.menuTriggerRef = triggerRef;
}
public showMenu(event: React.MouseEvent<HTMLDivElement>) {
if (this.menuTriggerRef) {
this.menuTriggerRef.handleContextClick(event);
}
}
public renderMenu(isCorrectSide: boolean, triggerId: string) {
const {
attachments,
direction,
disableMenu,
downloadAttachment,
id,
isSticker,
isTapToView,
replyToMessage,
timestamp,
} = this.props;
if (!isCorrectSide || disableMenu) {
return null;
}
const fileName =
attachments && attachments[0] ? attachments[0].fileName : null;
const isDangerous = isFileDangerous(fileName || '');
const multipleAttachments = attachments && attachments.length > 1;
const firstAttachment = attachments && attachments[0];
@ -892,13 +883,8 @@ export class Message extends React.PureComponent<Props, State> {
firstAttachment &&
!firstAttachment.pending ? (
<div
onClick={() => {
downloadAttachment({
isDangerous,
attachment: firstAttachment,
timestamp,
});
}}
onClick={this.openGenericAttachment}
// This a menu meant for mouse use only
role="button"
className={classNames(
'module-message__buttons__download',
@ -909,9 +895,13 @@ export class Message extends React.PureComponent<Props, State> {
const replyButton = (
<div
onClick={() => {
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
replyToMessage(id);
}}
// This a menu meant for mouse use only
role="button"
className={classNames(
'module-message__buttons__reply',
@ -921,10 +911,11 @@ export class Message extends React.PureComponent<Props, State> {
);
const menuButton = (
<ContextMenuTrigger id={triggerId} ref={this.captureMenuTriggerBound}>
<ContextMenuTrigger id={triggerId} ref={this.captureMenuTrigger as any}>
<div
// This a menu meant for mouse use only
role="button"
onClick={this.showMenuBound}
onClick={this.showMenu}
className={classNames(
'module-message__buttons__menu',
`module-message__buttons__download--${direction}`
@ -955,7 +946,6 @@ export class Message extends React.PureComponent<Props, State> {
attachments,
deleteMessage,
direction,
downloadAttachment,
i18n,
id,
isSticker,
@ -964,13 +954,9 @@ export class Message extends React.PureComponent<Props, State> {
retrySend,
showMessageDetail,
status,
timestamp,
} = this.props;
const showRetry = status === 'error' && direction === 'outgoing';
const fileName =
attachments && attachments[0] ? attachments[0].fileName : null;
const isDangerous = isFileDangerous(fileName || '');
const multipleAttachments = attachments && attachments.length > 1;
const menu = (
@ -984,13 +970,7 @@ export class Message extends React.PureComponent<Props, State> {
attributes={{
className: 'module-message__context__download',
}}
onClick={() => {
downloadAttachment({
attachment: attachments[0],
timestamp,
isDangerous,
});
}}
onClick={this.openGenericAttachment}
>
{i18n('downloadAttachment')}
</MenuItem>
@ -999,7 +979,10 @@ export class Message extends React.PureComponent<Props, State> {
attributes={{
className: 'module-message__context__reply',
}}
onClick={() => {
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
replyToMessage(id);
}}
>
@ -1009,7 +992,10 @@ export class Message extends React.PureComponent<Props, State> {
attributes={{
className: 'module-message__context__more-info',
}}
onClick={() => {
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
showMessageDetail(id);
}}
>
@ -1020,7 +1006,10 @@ export class Message extends React.PureComponent<Props, State> {
attributes={{
className: 'module-message__context__retry-send',
}}
onClick={() => {
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
retrySend(id);
}}
>
@ -1031,7 +1020,10 @@ export class Message extends React.PureComponent<Props, State> {
attributes={{
className: 'module-message__context__delete-message',
}}
onClick={() => {
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
deleteMessage(id);
}}
>
@ -1048,13 +1040,14 @@ export class Message extends React.PureComponent<Props, State> {
if (attachments && attachments.length) {
if (isSticker) {
// Padding is 8px, on both sides
return STICKER_SIZE + 8 * 2;
// Padding is 8px, on both sides, plus two for 1px border
return STICKER_SIZE + 8 * 2 + 2;
}
const dimensions = getGridDimensions(attachments);
if (dimensions) {
return dimensions.width;
// Add two for 1px border
return dimensions.width + 2;
}
}
@ -1074,7 +1067,8 @@ export class Message extends React.PureComponent<Props, State> {
) {
const dimensions = getImageDimensions(first.image);
if (dimensions) {
return dimensions.width;
// Add two for 1px border
return dimensions.width + 2;
}
}
}
@ -1249,25 +1243,188 @@ export class Message extends React.PureComponent<Props, State> {
}
// tslint:disable-next-line cyclomatic-complexity
public render() {
public handleOpen = (
event: React.KeyboardEvent<HTMLDivElement> | React.MouseEvent
) => {
const {
authorPhoneNumber,
authorColor,
attachments,
conversationType,
direction,
displayTapToViewMessage,
id,
isTapToView,
isTapToViewExpired,
showVisualAttachment,
} = this.props;
const { imageBroken } = this.state;
const isAttachmentPending = this.isAttachmentPending();
if (isTapToView) {
if (!isTapToViewExpired && !isAttachmentPending) {
event.preventDefault();
event.stopPropagation();
displayTapToViewMessage(id);
}
return;
}
if (
!imageBroken &&
attachments &&
attachments.length > 0 &&
!isAttachmentPending &&
canDisplayImage(attachments) &&
((isImage(attachments) && hasImage(attachments)) ||
(isVideo(attachments) && hasVideoScreenshot(attachments)))
) {
event.preventDefault();
event.stopPropagation();
const attachment = attachments[0];
showVisualAttachment({ attachment, messageId: id });
return;
}
if (
attachments &&
attachments.length === 1 &&
!isAttachmentPending &&
!isAudio(attachments)
) {
event.preventDefault();
event.stopPropagation();
this.openGenericAttachment();
return;
}
if (
!isAttachmentPending &&
isAudio(attachments) &&
this.audioRef &&
this.audioRef.current
) {
event.preventDefault();
event.stopPropagation();
if (this.audioRef.current.paused) {
// tslint:disable-next-line no-floating-promises
this.audioRef.current.play();
} else {
// tslint:disable-next-line no-floating-promises
this.audioRef.current.pause();
}
}
};
public openGenericAttachment = (event?: React.MouseEvent) => {
const { attachments, downloadAttachment, timestamp } = this.props;
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (!attachments || attachments.length !== 1) {
return;
}
const attachment = attachments[0];
const { fileName } = attachment;
const isDangerous = isFileDangerous(fileName || '');
downloadAttachment({
isDangerous,
attachment,
timestamp,
});
};
public handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== 'Space') {
return;
}
this.handleOpen(event);
};
public handleClick = (event: React.MouseEvent) => {
// We don't want clicks on body text to result in the 'default action' for the message
const { text } = this.props;
if (text && text.length > 0) {
return;
}
this.handleOpen(event);
};
public renderContainer() {
const {
authorColor,
direction,
isSelected,
isSticker,
isTapToView,
isTapToViewExpired,
isTapToViewError,
timestamp,
} = this.props;
const { expired, expiring, imageBroken, isSelected } = this.state;
const isAttachmentPending = this.isAttachmentPending();
const isButton = isTapToView && !isTapToViewExpired && !isAttachmentPending;
const width = this.getWidth();
const isShowingImage = this.isShowingImage();
const containerClassnames = classNames(
'module-message__container',
isSelected && !isSticker ? 'module-message__container--selected' : null,
isSticker ? 'module-message__container--with-sticker' : null,
!isSticker ? `module-message__container--${direction}` : null,
isTapToView ? 'module-message__container--with-tap-to-view' : null,
isTapToView && isTapToViewExpired
? 'module-message__container--with-tap-to-view-expired'
: null,
!isSticker && direction === 'incoming'
? `module-message__container--incoming-${authorColor}`
: null,
isTapToView && isAttachmentPending && !isTapToViewExpired
? 'module-message__container--with-tap-to-view-pending'
: null,
isTapToView && isAttachmentPending && !isTapToViewExpired
? `module-message__container--${direction}-${authorColor}-tap-to-view-pending`
: null,
isTapToViewError
? 'module-message__container--with-tap-to-view-error'
: null
);
const containerStyles = {
width: isShowingImage ? width : undefined,
};
return (
<div className={containerClassnames} style={containerStyles}>
{this.renderAuthor()}
{this.renderContents()}
{this.renderAvatar()}
</div>
);
}
// tslint:disable-next-line cyclomatic-complexity
public render() {
const {
authorPhoneNumber,
attachments,
conversationType,
direction,
id,
isSticker,
timestamp,
} = this.props;
const { expired, expiring, imageBroken } = this.state;
// This id is what connects our triple-dot click with our associated pop-up menu.
// It needs to be unique.
@ -1281,11 +1438,6 @@ export class Message extends React.PureComponent<Props, State> {
return null;
}
const width = this.getWidth();
const isShowingImage = this.isShowingImage();
const role = isButton ? 'button' : undefined;
const onClick = isButton ? () => displayTapToViewMessage(id) : undefined;
return (
<div
className={classNames(
@ -1294,44 +1446,18 @@ export class Message extends React.PureComponent<Props, State> {
expiring ? 'module-message--expired' : null,
conversationType === 'group' ? 'module-message--group' : null
)}
tabIndex={0}
// We pretend to be a button because we sometimes contain buttons and a button
// cannot be within another button
role="button"
onKeyDown={this.handleKeyDown}
onClick={this.handleClick}
onFocus={this.setSelected}
ref={this.focusRef}
>
{this.renderError(direction === 'incoming')}
{this.renderMenu(direction === 'outgoing', triggerId)}
<div
className={classNames(
'module-message__container',
isSelected && !isSticker
? 'module-message__container--selected'
: null,
isSticker ? 'module-message__container--with-sticker' : null,
!isSticker ? `module-message__container--${direction}` : null,
isTapToView ? 'module-message__container--with-tap-to-view' : null,
isTapToView && isTapToViewExpired
? 'module-message__container--with-tap-to-view-expired'
: null,
!isSticker && direction === 'incoming'
? `module-message__container--incoming-${authorColor}`
: null,
isTapToView && isAttachmentPending && !isTapToViewExpired
? 'module-message__container--with-tap-to-view-pending'
: null,
isTapToView && isAttachmentPending && !isTapToViewExpired
? `module-message__container--${direction}-${authorColor}-tap-to-view-pending`
: null,
isTapToViewError
? 'module-message__container--with-tap-to-view-error'
: null
)}
style={{
width: isShowingImage ? width : undefined,
}}
role={role}
onClick={onClick}
>
{this.renderAuthor()}
{this.renderContents()}
{this.renderAvatar()}
</div>
{this.renderContainer()}
{this.renderError(direction === 'outgoing')}
{this.renderMenu(direction === 'incoming', triggerId)}
{this.renderContextMenu(triggerId)}