signal-desktop/ts/components/LeftPane.tsx

510 lines
17 KiB
TypeScript
Raw Normal View History

// Copyright 2019-2021 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
2021-08-11 16:23:21 +00:00
import React, { useEffect, useCallback, useMemo } from 'react';
import Measure, { MeasuredComponentProps } from 'react-measure';
import { isNumber } from 'lodash';
2019-01-14 21:49:58 +00:00
import {
LeftPaneHelper,
FindDirection,
ToFindType,
} from './leftPane/LeftPaneHelper';
2019-01-14 21:49:58 +00:00
import {
LeftPaneInboxHelper,
LeftPaneInboxPropsType,
} from './leftPane/LeftPaneInboxHelper';
import {
LeftPaneSearchHelper,
LeftPaneSearchPropsType,
} from './leftPane/LeftPaneSearchHelper';
import {
LeftPaneArchiveHelper,
LeftPaneArchivePropsType,
} from './leftPane/LeftPaneArchiveHelper';
import {
LeftPaneComposeHelper,
LeftPaneComposePropsType,
} from './leftPane/LeftPaneComposeHelper';
2021-03-03 20:09:58 +00:00
import {
LeftPaneChooseGroupMembersHelper,
LeftPaneChooseGroupMembersPropsType,
} from './leftPane/LeftPaneChooseGroupMembersHelper';
import {
LeftPaneSetGroupMetadataHelper,
LeftPaneSetGroupMetadataPropsType,
} from './leftPane/LeftPaneSetGroupMetadataHelper';
import * as OS from '../OS';
import { LocalizerType, ScrollBehavior } from '../types/Util';
import { usePrevious } from '../util/hooks';
import { missingCaseError } from '../util/missingCaseError';
2019-01-14 21:49:58 +00:00
import { ConversationList } from './ConversationList';
2021-03-03 20:09:58 +00:00
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
2019-03-12 00:20:16 +00:00
2021-08-06 00:17:05 +00:00
import {
DeleteAvatarFromDiskActionType,
ReplaceAvatarActionType,
SaveAvatarToDiskActionType,
} from '../types/Avatar';
export enum LeftPaneMode {
Inbox,
Search,
Archive,
Compose,
2021-03-03 20:09:58 +00:00
ChooseGroupMembers,
SetGroupMetadata,
}
export type PropsType = {
// These help prevent invalid states. For example, we don't need the list of pinned
// conversations if we're trying to start a new conversation. Ideally these would be
// at the top level, but this is not supported by react-redux + TypeScript.
modeSpecificProps:
| ({
mode: LeftPaneMode.Inbox;
} & LeftPaneInboxPropsType)
| ({
mode: LeftPaneMode.Search;
} & LeftPaneSearchPropsType)
| ({
mode: LeftPaneMode.Archive;
} & LeftPaneArchivePropsType)
| ({
mode: LeftPaneMode.Compose;
2021-03-03 20:09:58 +00:00
} & LeftPaneComposePropsType)
| ({
mode: LeftPaneMode.ChooseGroupMembers;
} & LeftPaneChooseGroupMembersPropsType)
| ({
mode: LeftPaneMode.SetGroupMetadata;
} & LeftPaneSetGroupMetadataPropsType);
2019-01-14 21:49:58 +00:00
i18n: LocalizerType;
selectedConversationId: undefined | string;
selectedMessageId: undefined | string;
regionCode: string;
challengeStatus: 'idle' | 'required' | 'pending';
setChallengeStatus: (status: 'idle') => void;
2019-01-14 21:49:58 +00:00
// Action Creators
2021-03-03 20:09:58 +00:00
cantAddContactToGroup: (conversationId: string) => void;
clearGroupCreationError: () => void;
closeCantAddContactToGroupModal: () => void;
closeMaximumGroupSizeModal: () => void;
closeRecommendedGroupSizeModal: () => void;
createGroup: () => void;
startNewConversationFromPhoneNumber: (e164: string) => void;
openConversationInternal: (_: {
conversationId: string;
messageId?: string;
switchToAssociatedView?: boolean;
}) => void;
2021-03-03 20:09:58 +00:00
setComposeSearchTerm: (composeSearchTerm: string) => void;
setComposeGroupAvatar: (_: undefined | ArrayBuffer) => void;
setComposeGroupName: (_: string) => void;
setComposeGroupExpireTimer: (_: number) => void;
2019-03-12 00:20:16 +00:00
showArchivedConversations: () => void;
showInbox: () => void;
startComposing: () => void;
2021-03-03 20:09:58 +00:00
showChooseGroupMembers: () => void;
startSettingGroupMetadata: () => void;
toggleConversationInChooseMembers: (conversationId: string) => void;
2021-08-06 00:17:05 +00:00
composeDeleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
composeReplaceAvatar: ReplaceAvatarActionType;
composeSaveAvatarToDisk: SaveAvatarToDiskActionType;
toggleComposeEditingAvatar: () => unknown;
2019-01-14 21:49:58 +00:00
// Render Props
renderExpiredBuildDialog: () => JSX.Element;
2019-01-14 21:49:58 +00:00
renderMainHeader: () => JSX.Element;
2021-08-11 16:23:21 +00:00
renderMessageSearchResult: (id: string) => JSX.Element;
renderNetworkStatus: () => JSX.Element;
renderRelinkDialog: () => JSX.Element;
renderUpdateDialog: () => JSX.Element;
renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element;
};
2019-01-14 21:49:58 +00:00
export const LeftPane: React.FC<PropsType> = ({
2021-03-03 20:09:58 +00:00
cantAddContactToGroup,
2021-08-06 00:17:05 +00:00
challengeStatus,
2021-03-03 20:09:58 +00:00
clearGroupCreationError,
closeCantAddContactToGroupModal,
closeMaximumGroupSizeModal,
closeRecommendedGroupSizeModal,
2021-08-06 00:17:05 +00:00
composeDeleteAvatarFromDisk,
composeReplaceAvatar,
composeSaveAvatarToDisk,
2021-03-03 20:09:58 +00:00
createGroup,
i18n,
modeSpecificProps,
openConversationInternal,
2021-08-06 00:17:05 +00:00
renderCaptchaDialog,
renderExpiredBuildDialog,
renderMainHeader,
renderMessageSearchResult,
renderNetworkStatus,
renderRelinkDialog,
renderUpdateDialog,
selectedConversationId,
selectedMessageId,
2021-08-06 00:17:05 +00:00
setChallengeStatus,
2021-03-03 20:09:58 +00:00
setComposeGroupAvatar,
setComposeGroupExpireTimer,
2021-08-06 00:17:05 +00:00
setComposeGroupName,
setComposeSearchTerm,
showArchivedConversations,
2021-08-06 00:17:05 +00:00
showChooseGroupMembers,
showInbox,
startComposing,
startNewConversationFromPhoneNumber,
2021-03-03 20:09:58 +00:00
startSettingGroupMetadata,
2021-08-06 00:17:05 +00:00
toggleComposeEditingAvatar,
2021-03-03 20:09:58 +00:00
toggleConversationInChooseMembers,
}) => {
const previousModeSpecificProps = usePrevious(
modeSpecificProps,
modeSpecificProps
);
// The left pane can be in various modes: the inbox, the archive, the composer, etc.
// Ideally, this would render subcomponents such as `<LeftPaneInbox>` or
// `<LeftPaneArchive>` (and if there's a way to do that cleanly, we should refactor
// this).
//
// But doing that presents two problems:
//
// 1. Different components render the same logical inputs (the main header's search),
// but React doesn't know that they're the same, so you can lose focus as you change
// modes.
// 2. These components render virtualized lists, which are somewhat slow to initialize.
// Switching between modes can cause noticable hiccups.
//
// To get around those problems, we use "helpers" which all correspond to the same
// interface.
//
// Unfortunately, there's a little bit of repetition here because TypeScript isn't quite
// smart enough.
let helper: LeftPaneHelper<unknown>;
let shouldRecomputeRowHeights: boolean;
switch (modeSpecificProps.mode) {
case LeftPaneMode.Inbox: {
const inboxHelper = new LeftPaneInboxHelper(modeSpecificProps);
shouldRecomputeRowHeights =
previousModeSpecificProps.mode === modeSpecificProps.mode
? inboxHelper.shouldRecomputeRowHeights(previousModeSpecificProps)
: true;
helper = inboxHelper;
break;
}
case LeftPaneMode.Search: {
const searchHelper = new LeftPaneSearchHelper(modeSpecificProps);
shouldRecomputeRowHeights =
previousModeSpecificProps.mode === modeSpecificProps.mode
? searchHelper.shouldRecomputeRowHeights(previousModeSpecificProps)
: true;
helper = searchHelper;
break;
}
case LeftPaneMode.Archive: {
const archiveHelper = new LeftPaneArchiveHelper(modeSpecificProps);
shouldRecomputeRowHeights =
previousModeSpecificProps.mode === modeSpecificProps.mode
? archiveHelper.shouldRecomputeRowHeights(previousModeSpecificProps)
: true;
helper = archiveHelper;
break;
2019-03-12 00:20:16 +00:00
}
case LeftPaneMode.Compose: {
const composeHelper = new LeftPaneComposeHelper(modeSpecificProps);
shouldRecomputeRowHeights =
previousModeSpecificProps.mode === modeSpecificProps.mode
? composeHelper.shouldRecomputeRowHeights(previousModeSpecificProps)
: true;
helper = composeHelper;
break;
2019-03-12 00:20:16 +00:00
}
2021-03-03 20:09:58 +00:00
case LeftPaneMode.ChooseGroupMembers: {
const chooseGroupMembersHelper = new LeftPaneChooseGroupMembersHelper(
modeSpecificProps
);
shouldRecomputeRowHeights =
previousModeSpecificProps.mode === modeSpecificProps.mode
? chooseGroupMembersHelper.shouldRecomputeRowHeights(
previousModeSpecificProps
)
: true;
helper = chooseGroupMembersHelper;
break;
}
case LeftPaneMode.SetGroupMetadata: {
const setGroupMetadataHelper = new LeftPaneSetGroupMetadataHelper(
modeSpecificProps
);
shouldRecomputeRowHeights =
previousModeSpecificProps.mode === modeSpecificProps.mode
? setGroupMetadataHelper.shouldRecomputeRowHeights(
previousModeSpecificProps
)
: true;
helper = setGroupMetadataHelper;
break;
}
default:
throw missingCaseError(modeSpecificProps);
}
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const { ctrlKey, shiftKey, altKey, metaKey, key } = event;
const commandOrCtrl = OS.isMacOS() ? metaKey : ctrlKey;
2021-05-19 18:32:12 +00:00
if (event.key === 'Escape') {
const backAction = helper.getBackAction({
showInbox,
startComposing,
showChooseGroupMembers,
});
if (backAction) {
event.preventDefault();
event.stopPropagation();
backAction();
return;
}
}
if (
commandOrCtrl &&
!shiftKey &&
!altKey &&
(key === 'n' || key === 'N')
) {
startComposing();
event.preventDefault();
event.stopPropagation();
return;
2019-11-07 21:36:16 +00:00
}
let conversationToOpen:
| undefined
| {
conversationId: string;
messageId?: string;
};
2019-11-07 21:36:16 +00:00
const numericIndex = keyboardKeyToNumericIndex(event.key);
if (commandOrCtrl && isNumber(numericIndex)) {
conversationToOpen = helper.getConversationAndMessageAtIndex(
numericIndex
2020-09-12 00:46:52 +00:00
);
} else {
let toFind: undefined | ToFindType;
if (
(altKey && !shiftKey && key === 'ArrowUp') ||
(commandOrCtrl && shiftKey && key === '[') ||
(ctrlKey && shiftKey && key === 'Tab')
) {
toFind = { direction: FindDirection.Up, unreadOnly: false };
} else if (
(altKey && !shiftKey && key === 'ArrowDown') ||
(commandOrCtrl && shiftKey && key === ']') ||
(ctrlKey && key === 'Tab')
) {
toFind = { direction: FindDirection.Down, unreadOnly: false };
} else if (altKey && shiftKey && key === 'ArrowUp') {
toFind = { direction: FindDirection.Up, unreadOnly: true };
} else if (altKey && shiftKey && key === 'ArrowDown') {
toFind = { direction: FindDirection.Down, unreadOnly: true };
2019-11-07 21:36:16 +00:00
}
if (toFind) {
conversationToOpen = helper.getConversationAndMessageInDirection(
toFind,
selectedConversationId,
selectedMessageId
);
2019-11-07 21:36:16 +00:00
}
}
if (conversationToOpen) {
const { conversationId, messageId } = conversationToOpen;
openConversationInternal({ conversationId, messageId });
event.preventDefault();
event.stopPropagation();
}
};
2019-03-12 00:20:16 +00:00
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, [
helper,
openConversationInternal,
selectedConversationId,
selectedMessageId,
2021-05-19 18:32:12 +00:00
showChooseGroupMembers,
showInbox,
startComposing,
]);
const preRowsNode = helper.getPreRowsNode({
2021-03-03 20:09:58 +00:00
clearGroupCreationError,
closeCantAddContactToGroupModal,
closeMaximumGroupSizeModal,
closeRecommendedGroupSizeModal,
2021-08-06 00:17:05 +00:00
composeDeleteAvatarFromDisk,
composeReplaceAvatar,
composeSaveAvatarToDisk,
2021-03-03 20:09:58 +00:00
createGroup,
i18n,
2021-03-03 20:09:58 +00:00
setComposeGroupAvatar,
setComposeGroupName,
setComposeGroupExpireTimer,
2021-08-06 00:17:05 +00:00
toggleComposeEditingAvatar,
onChangeComposeSearchTerm: event => {
setComposeSearchTerm(event.target.value);
},
2021-03-03 20:09:58 +00:00
removeSelectedContact: toggleConversationInChooseMembers,
});
const footerContents = helper.getFooterContents({
createGroup,
i18n,
startSettingGroupMetadata,
});
2021-03-03 20:09:58 +00:00
const getRow = useMemo(() => helper.getRow.bind(helper), [helper]);
2021-08-11 16:23:21 +00:00
const onSelectConversation = useCallback(
(conversationId: string, messageId?: string) => {
openConversationInternal({
conversationId,
messageId,
switchToAssociatedView: true,
});
},
[openConversationInternal]
);
const previousSelectedConversationId = usePrevious(
selectedConversationId,
selectedConversationId
);
const isScrollable = helper.isScrollable();
let rowIndexToScrollTo: undefined | number;
let scrollBehavior: ScrollBehavior;
if (isScrollable) {
rowIndexToScrollTo =
previousSelectedConversationId === selectedConversationId
? undefined
: helper.getRowIndexToScrollTo(selectedConversationId);
scrollBehavior = ScrollBehavior.Default;
} else {
rowIndexToScrollTo = 0;
scrollBehavior = ScrollBehavior.Hard;
}
// We ensure that the listKey differs between some modes (e.g. inbox/archived), ensuring
// that AutoSizer properly detects the new size of its slot in the flexbox. The
// archive explainer text at the top of the archive view causes problems otherwise.
// It also ensures that we scroll to the top when switching views.
const listKey = preRowsNode ? 1 : 0;
// We disable this lint rule because we're trying to capture bubbled events. See [the
// lint rule's docs][0].
//
// [0]: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/645900a0e296ca7053dbf6cd9e12cc85849de2d5/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events
/* eslint-disable jsx-a11y/no-static-element-interactions */
return (
2021-05-19 18:32:12 +00:00
<div className="module-left-pane">
{/* eslint-enable jsx-a11y/no-static-element-interactions */}
<div className="module-left-pane__header">
2021-03-03 20:09:58 +00:00
{helper.getHeaderContents({
i18n,
showInbox,
startComposing,
showChooseGroupMembers,
}) || renderMainHeader()}
2019-03-12 00:20:16 +00:00
</div>
{renderExpiredBuildDialog()}
{renderRelinkDialog()}
2021-05-19 18:32:12 +00:00
{renderNetworkStatus()}
{renderUpdateDialog()}
{preRowsNode && <React.Fragment key={0}>{preRowsNode}</React.Fragment>}
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => (
<div className="module-left-pane__list--measure" ref={measureRef}>
<div className="module-left-pane__list--wrapper">
<div
aria-live="polite"
className="module-left-pane__list"
key={listKey}
role="presentation"
tabIndex={-1}
>
<ConversationList
dimensions={contentRect.bounds}
getRow={getRow}
i18n={i18n}
onClickArchiveButton={showArchivedConversations}
2021-03-03 20:09:58 +00:00
onClickContactCheckbox={(
conversationId: string,
disabledReason: undefined | ContactCheckboxDisabledReason
) => {
switch (disabledReason) {
case undefined:
toggleConversationInChooseMembers(conversationId);
break;
2021-03-11 21:29:31 +00:00
case ContactCheckboxDisabledReason.AlreadyAdded:
2021-03-03 20:09:58 +00:00
case ContactCheckboxDisabledReason.MaximumContactsSelected:
2021-03-11 21:29:31 +00:00
// These are no-ops.
2021-03-03 20:09:58 +00:00
break;
case ContactCheckboxDisabledReason.NotCapable:
cantAddContactToGroup(conversationId);
break;
default:
throw missingCaseError(disabledReason);
}
}}
2021-08-11 16:23:21 +00:00
onSelectConversation={onSelectConversation}
renderMessageSearchResult={renderMessageSearchResult}
rowCount={helper.getRowCount()}
scrollBehavior={scrollBehavior}
scrollToRowIndex={rowIndexToScrollTo}
scrollable={isScrollable}
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
2021-03-03 20:09:58 +00:00
showChooseGroupMembers={showChooseGroupMembers}
startNewConversationFromPhoneNumber={
startNewConversationFromPhoneNumber
}
/>
</div>
</div>
</div>
)}
</Measure>
2021-03-03 20:09:58 +00:00
{footerContents && (
<div className="module-left-pane__footer">{footerContents}</div>
)}
{challengeStatus !== 'idle' &&
renderCaptchaDialog({
onSkip() {
setChallengeStatus('idle');
},
})}
</div>
);
};
function keyboardKeyToNumericIndex(key: string): undefined | number {
if (key.length !== 1) {
return undefined;
}
const result = parseInt(key, 10) - 1;
const isValidIndex = Number.isInteger(result) && result >= 0 && result <= 8;
return isValidIndex ? result : undefined;
2019-01-14 21:49:58 +00:00
}