Switched ForwardMessageModal to use ListTile

This commit is contained in:
Alvaro 2023-01-25 16:51:08 -07:00 committed by GitHub
parent 257f5e1231
commit d64e0b65c4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 528 additions and 239 deletions

View file

@ -122,6 +122,8 @@ const rules = {
'react/display-name': 'error',
'react/jsx-pascal-case': ['error', {allowNamespace: true}],
// Allow returning values from promise executors for brevity.
'no-promise-executor-return': 'off',

View file

@ -4405,6 +4405,13 @@ button.module-image__border-overlay:focus {
padding-left: 10px;
padding-right: 10px;
// list tiles in choose-group-members and compose extend to the edge
.module-left-pane--mode-choose-group-members &,
.module-left-pane--mode-compose & {
padding-left: 0;
padding-right: 0;
}
&--width-narrow {
padding-left: 10px;
padding-right: 10px;

View file

@ -64,4 +64,9 @@
padding: 0;
}
}
.module-conversation-list {
// remove horizontal padding so ListTiles extend to the edges
padding: 0;
}
}

View file

@ -10,10 +10,10 @@
max-height: 88px;
overflow-x: hidden;
overflow-y: scroll;
padding-left: 12px;
padding: 4px 24px;
gap: 8px 12px;
.module-ContactPill {
margin: 4px 6px;
max-width: calc(
100% - 15px
); // 6px for the right margin and 9px for the scrollbar

View file

@ -22,6 +22,11 @@
color: $color-gray-05;
}
.module-conversation-list {
// remove horizontal padding so ListTiles extend to the edges
padding: 0;
}
&--link-preview {
border-bottom: 1px solid $color-gray-15;
padding: 12px 16px;

View file

@ -1,10 +1,11 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { noop, pick } from 'lodash';
import React from 'react';
import { pick } from 'lodash';
import React, { useCallback } from 'react';
import type { MeasuredComponentProps } from 'react-measure';
import Measure from 'react-measure';
import type { ListRowProps } from 'react-virtualized';
import type { ConversationType } from '../state/ducks/conversations';
import type {
@ -15,12 +16,16 @@ import type {
import { ToastType } from '../types/Toast';
import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations';
import { ConfirmationDialog } from './ConfirmationDialog';
import type { Row } from './ConversationList';
import { ConversationList, RowType } from './ConversationList';
import { DisabledReason } from './conversationList/GroupListItem';
import type { GroupListItemConversationType } from './conversationList/GroupListItem';
import {
DisabledReason,
GroupListItem,
} from './conversationList/GroupListItem';
import { Modal } from './Modal';
import { SearchInput } from './SearchInput';
import { useRestoreFocus } from '../hooks/useRestoreFocus';
import { ListView } from './ListView';
import { ListTile } from './ListTile';
type OwnProps = {
i18n: LocalizerType;
@ -47,7 +52,6 @@ export type Props = OwnProps & DispatchProps;
export function AddUserToAnotherGroupModal({
i18n,
theme,
contact,
toggleAddUserToAnotherGroupModal,
addMembersToGroup,
@ -108,7 +112,7 @@ export function AddUserToAnotherGroupModal({
);
const handleGetRow = React.useCallback(
(idx: number): Row | undefined => {
(idx: number): GroupListItemConversationType => {
const convo = filteredConversations[idx];
// these are always populated in the case of a group
@ -129,18 +133,36 @@ export function AddUserToAnotherGroupModal({
}
return {
type: RowType.SelectSingleGroup,
group: {
...pick(convo, 'id', 'avatarPath', 'title', 'unblurredAvatarPath'),
memberships,
membersCount,
disabledReason,
},
...pick(convo, 'id', 'avatarPath', 'title', 'unblurredAvatarPath'),
memberships,
membersCount,
disabledReason,
};
},
[filteredConversations, contact]
);
const renderGroupListItem = useCallback(
({ key, index, style }: ListRowProps) => {
const group = handleGetRow(index);
return (
<div key={key} style={style}>
<GroupListItem
i18n={i18n}
group={group}
onSelectGroup={setSelectedGroupId}
/>
</div>
);
},
[i18n, handleGetRow]
);
const handleCalculateRowHeight = useCallback(
() => ListTile.heightCompact,
[]
);
return (
<>
{!selectedGroup && (
@ -163,30 +185,30 @@ export function AddUserToAnotherGroupModal({
/>
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => (
<div
className="AddUserToAnotherGroupModal__list-wrapper"
ref={measureRef}
>
<ConversationList
dimensions={contentRect.bounds}
getPreferredBadge={() => undefined}
getRow={handleGetRow}
i18n={i18n}
lookupConversationWithoutUuid={async _ => undefined}
onClickArchiveButton={noop}
onClickContactCheckbox={noop}
onSelectConversation={setSelectedGroupId}
rowCount={filteredConversations.length}
setIsFetchingUUID={noop}
shouldRecomputeRowHeights={false}
showChooseGroupMembers={noop}
showConversation={noop}
showUserNotFoundModal={noop}
theme={theme}
/>
</div>
)}
{({ contentRect, measureRef }: MeasuredComponentProps) => {
// Though `width` and `height` are required properties, we want to be
// careful in case the caller sends bogus data. Notably, react-measure's
// types seem to be inaccurate.
const { width = 100, height = 100 } = contentRect.bounds || {};
if (!width || !height) {
return null;
}
return (
<div
className="AddUserToAnotherGroupModal__list-wrapper"
ref={measureRef}
>
<ListView
width={width}
height={height}
rowCount={filteredConversations.length}
calculateRowHeight={handleCalculateRowHeight}
rowRenderer={renderGroupListItem}
/>
</div>
);
}}
</Measure>
</div>
</Modal>

View file

@ -215,6 +215,9 @@ export function ConversationList({
case RowType.SearchResultsLoadingFakeHeader:
return HEADER_ROW_HEIGHT;
case RowType.SelectSingleGroup:
case RowType.ContactCheckbox:
case RowType.Contact:
case RowType.CreateNewGroup:
return SELECT_ROW_HEIGHT;
default:
return NORMAL_ROW_HEIGHT;

View file

@ -38,6 +38,7 @@ import {
shouldNeverBeCalled,
asyncShouldNeverBeCalled,
} from '../util/shouldNeverBeCalled';
import { Emojify } from './conversation/Emojify';
export type DataPropsType = {
attachments?: ReadonlyArray<AttachmentType>;
@ -408,8 +409,13 @@ export function ForwardMessageModal({
)}
<div className="module-ForwardMessageModal__footer">
<div>
{Boolean(selectedContacts.length) &&
selectedContacts.map(contact => contact.title).join(', ')}
{Boolean(selectedContacts.length) && (
<Emojify
text={selectedContacts
.map(contact => contact.title)
.join(', ')}
/>
)}
</div>
<div>
{isEditingMessage || !isMessageEditable ? (

View file

@ -23,14 +23,18 @@ import { setupI18n } from '../util/setupI18n';
import { DurationInSeconds, DAY } from '../util/durations';
import enMessages from '../../_locales/en/messages.json';
import { ThemeType } from '../types/Util';
import {
getDefaultConversation,
getDefaultGroupListItem,
} from '../test-both/helpers/getDefaultConversation';
import { DialogType } from '../types/Dialogs';
import { SocketStatus } from '../types/SocketStatus';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
import {
makeFakeLookupConversationWithoutUuid,
useUuidFetchState,
} from '../test-both/helpers/fakeLookupConversationWithoutUuid';
import type { GroupListItemConversationType } from './conversationList/GroupListItem';
const i18n = setupI18n('en', enMessages);
@ -62,18 +66,14 @@ const defaultSearchProps = {
startSearchCounter: 0,
};
const defaultGroups: Array<ConversationType> = [
getDefaultConversation({
const defaultGroups: Array<GroupListItemConversationType> = [
getDefaultGroupListItem({
id: 'biking-group',
title: 'Mtn Biking Arizona 🚵☀️⛰',
type: 'group',
sharedGroupNames: [],
}),
getDefaultConversation({
getDefaultGroupListItem({
id: 'dance-group',
title: 'Are we dancers? 💃',
type: 'group',
sharedGroupNames: [],
}),
];

View file

@ -605,7 +605,11 @@ export function LeftPane({
className={classNames(
'module-left-pane',
isResizing && 'module-left-pane--is-resizing',
`module-left-pane--width-${widthBreakpoint}`
`module-left-pane--width-${widthBreakpoint}`,
modeSpecificProps.mode === LeftPaneMode.ChooseGroupMembers &&
'module-left-pane--mode-choose-group-members',
modeSpecificProps.mode === LeftPaneMode.Compose &&
'module-left-pane--mode-compose'
)}
style={{ width }}
>

View file

@ -2,8 +2,10 @@
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
import React from 'react';
import React, { useMemo } from 'react';
import uuid from 'uuid';
import { getClassNamesFor } from '../util/getClassNamesFor';
import { CircleCheckbox } from './CircleCheckbox';
export type Props = {
title: string | JSX.Element;
@ -23,6 +25,7 @@ export type Props = {
variant?: 'item' | 'panelrow';
// defaults to div
rootElement?: 'div' | 'button';
testId?: string;
};
const getClassName = getClassNamesFor('ListTile');
@ -53,8 +56,17 @@ const getClassName = getClassNamesFor('ListTile');
* - panelrow: more horizontal padding, intended for information rows (usually not in
* modals) that tend to occupy more horizontal space
*/
export const ListTile = React.forwardRef<HTMLButtonElement, Props>(
function ListTile(
export function ListTile(
params: Props & React.RefAttributes<HTMLButtonElement>
): JSX.Element {
// forwardRef makes it impossible to add extra static fields to the function type so
// we have to create this inner implementation that can be wrapped with a non-arrow
// function. A bit weird, but looks fine at call-site.
return <ListTileImpl {...params} />;
}
const ListTileImpl = React.forwardRef<HTMLButtonElement, Props>(
function ListTileImpl(
{
title,
subtitle,
@ -67,6 +79,7 @@ export const ListTile = React.forwardRef<HTMLButtonElement, Props>(
disabled = false,
variant = 'item',
rootElement = 'div',
testId,
}: Props,
ref
) {
@ -81,6 +94,7 @@ export const ListTile = React.forwardRef<HTMLButtonElement, Props>(
onClick,
'aria-disabled': disabled ? true : undefined,
onContextMenu,
'data-testid': testId,
};
const contents = (
@ -112,3 +126,52 @@ export const ListTile = React.forwardRef<HTMLButtonElement, Props>(
);
}
);
// although these heights are not required for ListTile (which sizes itself based on
// content), they are useful as constants for ListView.calculateRowHeight
/** Effective ListTile height for an avatar (leading) size 36 */
ListTile.heightFull = 64;
/** Effective ListTile height for an avatar (leading) size 48 */
ListTile.heightCompact = 52;
/**
* ListTile with a trailing checkbox.
*
* It also wraps the ListTile with a <label> to get typical click-label-to-check behavior
*
* Same API except for:
* - no "trailing" param since it is populated by the checkbox
* - isChecked
*/
ListTile.checkbox = (
props: Omit<Props, 'trailing'> & { isChecked: boolean }
) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const htmlId: string = useMemo(() => uuid(), []);
const { onClick, disabled, isChecked, ...otherProps } = props;
return (
<label
htmlFor={htmlId}
// `onClick` is will double-fire if we're enabled. We want it to fire when we're
// disabled so we can show any "can't add contact" modals, etc. This won't
// work for keyboard users, though, because labels are not tabbable.
{...(disabled ? { onClick } : {})}
>
<ListTile
{...otherProps}
disabled={disabled}
trailing={
<CircleCheckbox
id={htmlId}
checked={isChecked}
onChange={onClick}
disabled={disabled}
/>
}
/>
</label>
);
};

View file

@ -1,6 +1,8 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable react/jsx-pascal-case */
import type { ReactNode, RefObject } from 'react';
import React from 'react';
import { createPortal } from 'react-dom';

View file

@ -1,6 +1,8 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable local-rules/valid-i18n-keys */
import React, {
useEffect,
useMemo,
@ -8,13 +10,14 @@ import React, {
useRef,
useCallback,
} from 'react';
import { omit } from 'lodash';
import { noop, omit } from 'lodash';
import type { MeasuredComponentProps } from 'react-measure';
import Measure from 'react-measure';
import type { ListRowProps } from 'react-virtualized';
import type { LocalizerType, ThemeType } from '../../../../types/Util';
import { getUsernameFromSearch } from '../../../../types/Username';
import { strictAssert } from '../../../../util/assert';
import { strictAssert, assertDev } from '../../../../util/assert';
import { refMerger } from '../../../../util/refMerger';
import { useRestoreFocus } from '../../../../hooks/useRestoreFocus';
import { missingCaseError } from '../../../../util/missingCaseError';
@ -36,11 +39,16 @@ import { ModalHost } from '../../../ModalHost';
import { ContactPills } from '../../../ContactPills';
import { ContactPill } from '../../../ContactPill';
import type { Row } from '../../../ConversationList';
import { ConversationList, RowType } from '../../../ConversationList';
import { ContactCheckboxDisabledReason } from '../../../conversationList/ContactCheckbox';
import { RowType } from '../../../ConversationList';
import {
ContactCheckbox,
ContactCheckboxDisabledReason,
} from '../../../conversationList/ContactCheckbox';
import { Button, ButtonVariant } from '../../../Button';
import { SearchInput } from '../../../SearchInput';
import { shouldNeverBeCalled } from '../../../../util/shouldNeverBeCalled';
import { ListView } from '../../../ListView';
import { UsernameCheckbox } from '../../../conversationList/UsernameCheckbox';
import { PhoneNumberCheckbox } from '../../../conversationList/PhoneNumberCheckbox';
export type StatePropsType = {
regionCode: string | undefined;
@ -77,7 +85,6 @@ export function ChooseGroupMembersModal({
candidateContacts,
confirmAdds,
conversationIdsAlreadyInGroup,
getPreferredBadge,
i18n,
maxGroupSize,
onClose,
@ -278,6 +285,94 @@ export function ChooseGroupMembersModal({
return undefined;
};
const handleContactClick = (
conversationId: string,
disabledReason: undefined | ContactCheckboxDisabledReason
) => {
switch (disabledReason) {
case undefined:
toggleSelectedContact(conversationId);
break;
case ContactCheckboxDisabledReason.AlreadyAdded:
case ContactCheckboxDisabledReason.MaximumContactsSelected:
// These are no-ops.
break;
default:
throw missingCaseError(disabledReason);
}
};
const renderItem = ({ key, index, style }: ListRowProps) => {
const row = getRow(index);
let item;
switch (row?.type) {
case RowType.Header:
item = (
<div
className="module-conversation-list__item--header"
aria-label={i18n(row.i18nKey)}
>
{i18n(row.i18nKey)}
</div>
);
break;
case RowType.ContactCheckbox:
item = (
<ContactCheckbox
i18n={i18n}
theme={theme}
{...row.contact}
onClick={handleContactClick}
isChecked={row.isChecked}
badge={undefined}
/>
);
break;
case RowType.UsernameCheckbox:
item = (
<UsernameCheckbox
i18n={i18n}
theme={theme}
username={row.username}
isChecked={row.isChecked}
isFetching={row.isFetching}
toggleConversationInChooseMembers={conversationId =>
handleContactClick(conversationId, undefined)
}
showUserNotFoundModal={noop}
setIsFetchingUUID={setIsFetchingUUID}
lookupConversationWithoutUuid={() => Promise.resolve(undefined)}
/>
);
break;
case RowType.PhoneNumberCheckbox:
item = (
<PhoneNumberCheckbox
phoneNumber={row.phoneNumber}
lookupConversationWithoutUuid={lookupConversationWithoutUuid}
showUserNotFoundModal={showUserNotFoundModal}
setIsFetchingUUID={setIsFetchingUUID}
toggleConversationInChooseMembers={conversationId =>
handleContactClick(conversationId, undefined)
}
isChecked={row.isChecked}
isFetching={row.isFetching}
i18n={i18n}
theme={theme}
/>
);
break;
default:
}
return (
<div key={key} style={style}>
{item}
</div>
);
};
return (
<ModalHost
modalName="AddGroupMembersModal.ChooseGroupMembersModal"
@ -335,6 +430,14 @@ export function ChooseGroupMembersModal({
{rowCount ? (
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => {
// Though `width` and `height` are required properties, we want to be
// careful in case the caller sends bogus data. Notably, react-measure's
// types seem to be inaccurate.
const { width = 100, height = 100 } = contentRect.bounds || {};
if (!width || !height) {
return null;
}
// We disable this ESLint rule because we're capturing a bubbled keydown
// event. See [this note in the jsx-a11y docs][0].
//
@ -350,43 +453,25 @@ export function ChooseGroupMembersModal({
}
}}
>
<ConversationList
dimensions={contentRect.bounds}
getPreferredBadge={getPreferredBadge}
getRow={getRow}
i18n={i18n}
onClickArchiveButton={shouldNeverBeCalled}
onClickContactCheckbox={(
conversationId: string,
disabledReason: undefined | ContactCheckboxDisabledReason
) => {
switch (disabledReason) {
case undefined:
toggleSelectedContact(conversationId);
break;
case ContactCheckboxDisabledReason.AlreadyAdded:
case ContactCheckboxDisabledReason.MaximumContactsSelected:
// These are no-ops.
break;
<ListView
width={width}
height={height}
rowCount={rowCount}
calculateRowHeight={index => {
const row = getRow(index);
if (!row) {
assertDev(false, `Expected a row at index ${index}`);
return 52;
}
switch (row.type) {
case RowType.Header:
return 40;
default:
throw missingCaseError(disabledReason);
return 52;
}
}}
lookupConversationWithoutUuid={
lookupConversationWithoutUuid
}
showUserNotFoundModal={showUserNotFoundModal}
setIsFetchingUUID={setIsFetchingUUID}
showConversation={shouldNeverBeCalled}
onSelectConversation={shouldNeverBeCalled}
renderMessageSearchResult={() => {
shouldNeverBeCalled();
return <div />;
}}
rowCount={rowCount}
shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled}
theme={theme}
rowRenderer={renderItem}
/>
</div>
);

View file

@ -30,7 +30,7 @@ const MESSAGE_CLASS_NAME = `${CONTENT_CLASS_NAME}__message`;
export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`;
const CHECKBOX_CONTAINER_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox--container`;
const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`;
const SPINNER_CLASS_NAME = `${BASE_CLASS_NAME}__spinner`;
export const SPINNER_CLASS_NAME = `${BASE_CLASS_NAME}__spinner`;
type PropsType = {
checked?: boolean;

View file

@ -1,18 +1,17 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FunctionComponent, ReactNode } from 'react';
import React from 'react';
import type { FunctionComponent } from 'react';
import {
BaseConversationListItem,
HEADER_CONTACT_NAME_CLASS_NAME,
} from './BaseConversationListItem';
import { HEADER_CONTACT_NAME_CLASS_NAME } from './BaseConversationListItem';
import type { ConversationType } from '../../state/ducks/conversations';
import type { BadgeType } from '../../badges/types';
import type { LocalizerType, ThemeType } from '../../types/Util';
import { ContactName } from '../conversation/ContactName';
import { About } from '../conversation/About';
import { ListTile } from '../ListTile';
import { Avatar, AvatarSize } from '../Avatar';
export enum ContactCheckboxDisabledReason {
// We start the enum at 1 because the default starting value of 0 is falsy.
@ -61,7 +60,6 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
badge,
color,
disabledReason,
groupId,
i18n,
id,
isChecked,
@ -74,7 +72,6 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
title,
type,
unblurredAvatarPath,
uuid,
}) {
const disabled = Boolean(disabledReason);
@ -86,13 +83,11 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
<ContactName module={HEADER_CONTACT_NAME_CLASS_NAME} title={title} />
);
let messageText: ReactNode;
let messageText: undefined | string | JSX.Element;
if (disabledReason === ContactCheckboxDisabledReason.AlreadyAdded) {
messageText = i18n('alreadyAMember');
} else if (about) {
messageText = <About className="" text={about} />;
} else {
messageText = null;
}
const onClickItem = () => {
@ -100,29 +95,33 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
};
return (
<BaseConversationListItem
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
badge={badge}
checked={isChecked}
color={color}
conversationType={type}
<ListTile.checkbox
clickable
disabled={disabled}
groupId={groupId}
headerName={headerName}
i18n={i18n}
id={id}
isMe={isMe}
isSelected={false}
messageText={messageText}
isChecked={isChecked}
leading={
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color}
conversationType={type}
noteToSelf={Boolean(isMe)}
i18n={i18n}
isMe={isMe}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
unblurredAvatarPath={unblurredAvatarPath}
// appease the type checker.
{...(badge ? { badge, theme } : { badge: undefined })}
/>
}
title={headerName}
subtitle={isMe ? undefined : messageText}
subtitleMaxLines={1}
onClick={onClickItem}
phoneNumber={phoneNumber}
profileName={profileName}
sharedGroupNames={sharedGroupNames}
theme={theme}
title={title}
unblurredAvatarPath={unblurredAvatarPath}
uuid={uuid}
/>
);
}

View file

@ -4,15 +4,14 @@
import type { FunctionComponent } from 'react';
import React from 'react';
import {
BaseConversationListItem,
HEADER_CONTACT_NAME_CLASS_NAME,
} from './BaseConversationListItem';
import { HEADER_CONTACT_NAME_CLASS_NAME } from './BaseConversationListItem';
import type { ConversationType } from '../../state/ducks/conversations';
import type { BadgeType } from '../../badges/types';
import type { LocalizerType, ThemeType } from '../../types/Util';
import { ContactName } from '../conversation/ContactName';
import { About } from '../conversation/About';
import { ListTile } from '../ListTile';
import { Avatar, AvatarSize } from '../Avatar';
import { isSignalConversation } from '../../util/isSignalConversation';
export type ContactListItemConversationType = Pick<
@ -55,7 +54,6 @@ export const ContactListItem: FunctionComponent<PropsType> = React.memo(
avatarPath,
badge,
color,
groupId,
i18n,
id,
isMe,
@ -82,30 +80,33 @@ export const ContactListItem: FunctionComponent<PropsType> = React.memo(
);
const messageText =
about && !isMe ? <About className="" text={about} /> : null;
about && !isMe ? <About className="" text={about} /> : undefined;
return (
<BaseConversationListItem
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
badge={badge}
color={color}
conversationType={type}
groupId={groupId}
headerName={headerName}
i18n={i18n}
id={id}
isMe={isMe}
isSelected={false}
messageText={messageText}
<ListTile
leading={
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color}
conversationType={type}
noteToSelf={Boolean(isMe)}
i18n={i18n}
isMe={isMe}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
unblurredAvatarPath={unblurredAvatarPath}
// This is here to appease the type checker.
{...(badge ? { badge, theme } : { badge: undefined })}
/>
}
title={headerName}
subtitle={messageText}
subtitleMaxLines={1}
onClick={onClick ? () => onClick(id) : undefined}
phoneNumber={phoneNumber}
profileName={profileName}
sharedGroupNames={sharedGroupNames}
theme={theme}
title={title}
unblurredAvatarPath={unblurredAvatarPath}
uuid={uuid}
/>
);
}

View file

@ -4,8 +4,9 @@
import type { FunctionComponent } from 'react';
import React from 'react';
import { BaseConversationListItem } from './BaseConversationListItem';
import type { LocalizerType } from '../../types/Util';
import { ListTile } from '../ListTile';
import { Avatar, AvatarSize } from '../Avatar';
type PropsType = {
i18n: LocalizerType;
@ -17,17 +18,22 @@ export const CreateNewGroupButton: FunctionComponent<PropsType> = React.memo(
const title = i18n('createNewGroupButton');
return (
<BaseConversationListItem
acceptedMessageRequest={false}
conversationType="group"
headerName={title}
i18n={i18n}
isMe={false}
isSelected={false}
onClick={onClick}
sharedGroupNames={[]}
<ListTile
testId="CreateNewGroupButton"
leading={
<Avatar
acceptedMessageRequest={false}
conversationType="group"
i18n={i18n}
isMe={false}
title={title}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_TWO}
badge={undefined}
/>
}
title={title}
onClick={onClick}
/>
);
}

View file

@ -5,8 +5,9 @@ import React from 'react';
import type { ConversationType } from '../../state/ducks/conversations';
import type { LocalizerType } from '../../types/Util';
import type { UUIDStringType } from '../../types/UUID';
import { AvatarSize } from '../Avatar';
import { BaseConversationListItem } from './BaseConversationListItem';
import { Avatar, AvatarSize } from '../Avatar';
import { Emojify } from '../conversation/Emojify';
import { ListTile } from '../ListTile';
export enum DisabledReason {
AlreadyMember = 'already-member',
@ -49,21 +50,25 @@ export function GroupListItem({
count: group.membersCount,
});
}
return (
<BaseConversationListItem
disabled={group.disabledReason !== undefined}
conversationType="group"
title={group.title}
avatarSize={AvatarSize.THIRTY_TWO}
avatarPath={group.avatarPath}
acceptedMessageRequest
isMe={false}
sharedGroupNames={[]}
headerName={group.title}
i18n={i18n}
isSelected={false}
<ListTile
leading={
<Avatar
acceptedMessageRequest
avatarPath={group.avatarPath}
conversationType="group"
i18n={i18n}
isMe={false}
title={group.title}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_TWO}
badge={undefined}
/>
}
title={<Emojify text={group.title} />}
subtitle={<Emojify text={messageText} />}
onClick={() => onSelectGroup(group.id)}
messageText={messageText}
/>
);
}

View file

@ -6,11 +6,15 @@ import React, { useState } from 'react';
import { ButtonVariant } from '../Button';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { BaseConversationListItem } from './BaseConversationListItem';
import { SPINNER_CLASS_NAME } from './BaseConversationListItem';
import type { ParsedE164Type } from '../../util/libphonenumberInstance';
import type { LocalizerType, ThemeType } from '../../types/Util';
import { AvatarColors } from '../../types/Colors';
import type { LookupConversationWithoutUuidActionsType } from '../../util/lookupConversationWithoutUuid';
import { ListTile } from '../ListTile';
import { Emojify } from '../conversation/Emojify';
import { Avatar, AvatarSize } from '../Avatar';
import { Spinner } from '../Spinner';
export type PropsDataType = {
phoneNumber: ParsedE164Type;
@ -31,7 +35,6 @@ export const PhoneNumberCheckbox: FunctionComponent<PropsType> = React.memo(
phoneNumber,
isChecked,
isFetching,
theme,
i18n,
lookupConversationWithoutUuid,
showUserNotFoundModal,
@ -88,24 +91,46 @@ export const PhoneNumberCheckbox: FunctionComponent<PropsType> = React.memo(
);
}
const avatar = (
<Avatar
acceptedMessageRequest={false}
color={AvatarColors[0]}
conversationType="direct"
i18n={i18n}
isMe={false}
phoneNumber={phoneNumber.userInput}
title={phoneNumber.userInput}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_TWO}
badge={undefined}
/>
);
const title = <Emojify text={phoneNumber.userInput} />;
return (
<>
<BaseConversationListItem
acceptedMessageRequest={false}
checked={isChecked}
color={AvatarColors[0]}
conversationType="direct"
headerName={phoneNumber.userInput}
i18n={i18n}
isMe={false}
isSelected={false}
onClick={onClickItem}
phoneNumber={phoneNumber.userInput}
shouldShowSpinner={isFetching}
theme={theme}
sharedGroupNames={[]}
title={phoneNumber.userInput}
/>
{isFetching ? (
<ListTile
leading={avatar}
title={title}
trailing={
<Spinner
size="20px"
svgSize="small"
moduleClassName={SPINNER_CLASS_NAME}
direction="on-progress-dialog"
/>
}
/>
) : (
<ListTile.checkbox
isChecked={isChecked}
onClick={onClickItem}
leading={avatar}
title={<Emojify text={phoneNumber.userInput} />}
/>
)}
{modal}
</>
);

View file

@ -1,13 +1,16 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FunctionComponent } from 'react';
import React from 'react';
import type { FunctionComponent } from 'react';
import { BaseConversationListItem } from './BaseConversationListItem';
import type { LocalizerType, ThemeType } from '../../types/Util';
import { AvatarColors } from '../../types/Colors';
import type { LookupConversationWithoutUuidActionsType } from '../../util/lookupConversationWithoutUuid';
import { ListTile } from '../ListTile';
import { Avatar, AvatarSize } from '../Avatar';
import { Spinner } from '../Spinner';
import { SPINNER_CLASS_NAME } from './BaseConversationListItem';
export type PropsDataType = {
username: string;
@ -28,7 +31,6 @@ export const UsernameCheckbox: FunctionComponent<PropsType> = React.memo(
username,
isChecked,
isFetching,
theme,
i18n,
lookupConversationWithoutUuid,
showUserNotFoundModal,
@ -62,22 +64,41 @@ export const UsernameCheckbox: FunctionComponent<PropsType> = React.memo(
const title = i18n('at-username', { username });
return (
<BaseConversationListItem
const avatar = (
<Avatar
acceptedMessageRequest={false}
checked={isChecked}
color={AvatarColors[0]}
conversationType="direct"
headerName={title}
searchResult
i18n={i18n}
isMe={false}
isSelected={false}
isUsernameSearchResult
onClick={onClickItem}
shouldShowSpinner={isFetching}
theme={theme}
sharedGroupNames={[]}
title={title}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_TWO}
badge={undefined}
/>
);
return isFetching ? (
<ListTile
leading={avatar}
title={title}
trailing={
<Spinner
size="20px"
svgSize="small"
moduleClassName={SPINNER_CLASS_NAME}
direction="on-progress-dialog"
/>
}
/>
) : (
<ListTile.checkbox
leading={avatar}
title={title}
isChecked={isChecked}
onClick={onClickItem}
clickable
/>
);
}

View file

@ -8,7 +8,6 @@ import { LeftPaneHelper } from './LeftPaneHelper';
import type { Row } from '../ConversationList';
import { RowType } from '../ConversationList';
import type { ContactListItemConversationType } from '../conversationList/ContactListItem';
import type { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem';
import { SearchInput } from '../SearchInput';
import type { LocalizerType } from '../../types/Util';
import type { ParsedE164Type } from '../../util/libphonenumberInstance';
@ -20,10 +19,11 @@ import {
isFetchingByUsername,
isFetchingByE164,
} from '../../util/uuidFetchState';
import type { GroupListItemConversationType } from '../conversationList/GroupListItem';
export type LeftPaneComposePropsType = {
composeContacts: ReadonlyArray<ContactListItemConversationType>;
composeGroups: ReadonlyArray<ConversationListItemPropsType>;
composeGroups: ReadonlyArray<GroupListItemConversationType>;
regionCode: string | undefined;
searchTerm: string;
@ -39,7 +39,7 @@ enum TopButton {
export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsType> {
private readonly composeContacts: ReadonlyArray<ContactListItemConversationType>;
private readonly composeGroups: ReadonlyArray<ConversationListItemPropsType>;
private readonly composeGroups: ReadonlyArray<GroupListItemConversationType>;
private readonly uuidFetchState: UUIDFetchStateType;
@ -224,8 +224,8 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
const group = this.composeGroups[virtualRowIndex];
if (group) {
return {
type: RowType.Conversation,
conversation: group,
type: RowType.SelectSingleGroup,
group,
};
}

View file

@ -613,8 +613,25 @@ export const getFilteredComposeGroups = createSelector(
searchTerm: string,
groups: ReadonlyArray<ConversationType>,
regionCode: string | undefined
): Array<ConversationType> => {
return filterAndSortConversationsByRecent(groups, searchTerm, regionCode);
): Array<
ConversationType & {
membersCount: number;
disabledReason: undefined;
memberships: ReadonlyArray<unknown>;
}
> => {
return filterAndSortConversationsByRecent(
groups,
searchTerm,
regionCode
).map(group => ({
...group,
// we don't disable groups when composing, already filtered
disabledReason: undefined,
// should always be populated for a group
membersCount: group.membersCount ?? 0,
memberships: group.memberships ?? [],
}));
}
);

View file

@ -4,8 +4,9 @@
import casual from 'casual';
import { sample } from 'lodash';
import type { ConversationType } from '../../state/ducks/conversations';
import { UUID } from '../../types/UUID';
import type { UUIDStringType } from '../../types/UUID';
import { UUID } from '../../types/UUID';
import type { GroupListItemConversationType } from '../../components/conversationList/GroupListItem';
import { getRandomColor } from './getRandomColor';
import { ConversationColors } from '../../types/Colors';
import { StorySendMode } from '../../types/Stories';
@ -46,6 +47,18 @@ export function getDefaultConversation(
};
}
export function getDefaultGroupListItem(
overrideProps: Partial<GroupListItemConversationType> = {}
): GroupListItemConversationType {
return {
...getDefaultGroup(),
disabledReason: undefined,
membersCount: 24,
memberships: [],
...overrideProps,
};
}
export function getDefaultGroup(
overrideProps: Partial<ConversationType> = {}
): ConversationType {

View file

@ -113,9 +113,7 @@ describe('pnp/send gv2 invite', function needsName() {
.locator('.module-left-pane__compose-search-form__input')
.fill('ACI');
await leftPane
.locator(`[data-testid="${aciContact.toContact().uuid}"]`)
.click();
await leftPane.locator('.ListTile >> "ACI Contact"').click();
debug('inviting PNI member');
@ -123,11 +121,7 @@ describe('pnp/send gv2 invite', function needsName() {
.locator('.module-left-pane__compose-search-form__input')
.fill('PNI');
await leftPane
.locator(
`[data-testid="${pniContact.device.getUUIDByKind(UUIDKind.PNI)}"]`
)
.click();
await leftPane.locator('.ListTile >> "PNI Contact"').click();
await leftPane
.locator('.module-left-pane__footer button >> "Next"')

View file

@ -5,7 +5,10 @@ import { assert } from 'chai';
import * as sinon from 'sinon';
import { RowType } from '../../../components/ConversationList';
import { FindDirection } from '../../../components/leftPane/LeftPaneHelper';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import {
getDefaultConversation,
getDefaultGroupListItem,
} from '../../../test-both/helpers/getDefaultConversation';
import { LeftPaneComposeHelper } from '../../../components/leftPane/LeftPaneComposeHelper';
@ -69,7 +72,7 @@ describe('LeftPaneComposeHelper', () => {
assert.strictEqual(
new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [getDefaultGroupListItem(), getDefaultGroupListItem()],
regionCode: 'US',
searchTerm: '',
isUsernamesEnabled: true,
@ -83,7 +86,7 @@ describe('LeftPaneComposeHelper', () => {
assert.strictEqual(
new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [getDefaultGroupListItem(), getDefaultGroupListItem()],
regionCode: 'US',
searchTerm: 'someone',
isUsernamesEnabled: true,
@ -97,7 +100,7 @@ describe('LeftPaneComposeHelper', () => {
assert.strictEqual(
new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [getDefaultGroupListItem(), getDefaultGroupListItem()],
regionCode: 'US',
searchTerm: 'someone',
isUsernamesEnabled: false,
@ -133,7 +136,7 @@ describe('LeftPaneComposeHelper', () => {
assert.strictEqual(
new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [getDefaultConversation()],
composeGroups: [getDefaultGroupListItem()],
regionCode: 'US',
searchTerm: 'foobar',
isUsernamesEnabled: true,
@ -240,8 +243,8 @@ describe('LeftPaneComposeHelper', () => {
getDefaultConversation(),
];
const composeGroups = [
getDefaultConversation(),
getDefaultConversation(),
getDefaultGroupListItem(),
getDefaultGroupListItem(),
];
const helper = new LeftPaneComposeHelper({
composeContacts,
@ -255,6 +258,7 @@ describe('LeftPaneComposeHelper', () => {
assert.deepEqual(helper.getRow(0), {
type: RowType.CreateNewGroup,
});
assert.deepEqual(helper.getRow(1), {
type: RowType.Header,
i18nKey: 'contactsHeader',
@ -272,12 +276,12 @@ describe('LeftPaneComposeHelper', () => {
i18nKey: 'groupsHeader',
});
assert.deepEqual(helper.getRow(5), {
type: RowType.Conversation,
conversation: composeGroups[0],
type: RowType.SelectSingleGroup,
group: composeGroups[0],
});
assert.deepEqual(helper.getRow(6), {
type: RowType.Conversation,
conversation: composeGroups[1],
type: RowType.SelectSingleGroup,
group: composeGroups[1],
});
});
@ -571,7 +575,7 @@ describe('LeftPaneComposeHelper', () => {
assert.isTrue(
helperContacts.shouldRecomputeRowHeights({
composeContacts: [],
composeGroups: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [getDefaultGroupListItem(), getDefaultGroupListItem()],
regionCode: 'US',
searchTerm: 'foo bar',
isUsernamesEnabled: true,
@ -581,7 +585,7 @@ describe('LeftPaneComposeHelper', () => {
const helperGroups = new LeftPaneComposeHelper({
composeContacts: [],
composeGroups: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [getDefaultGroupListItem(), getDefaultGroupListItem()],
regionCode: 'US',
searchTerm: 'foo bar',
isUsernamesEnabled: true,
@ -603,7 +607,7 @@ describe('LeftPaneComposeHelper', () => {
it('should be true if the headers are in different row indices as before', () => {
const helperContacts = new LeftPaneComposeHelper({
composeContacts: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [getDefaultConversation()],
composeGroups: [getDefaultGroupListItem()],
regionCode: 'US',
searchTerm: 'soup',
isUsernamesEnabled: true,
@ -613,7 +617,7 @@ describe('LeftPaneComposeHelper', () => {
assert.isTrue(
helperContacts.shouldRecomputeRowHeights({
composeContacts: [getDefaultConversation()],
composeGroups: [getDefaultConversation(), getDefaultConversation()],
composeGroups: [getDefaultGroupListItem(), getDefaultGroupListItem()],
regionCode: 'US',
searchTerm: 'soup',
isUsernamesEnabled: true,