2023-01-03 19:55:46 +00:00
|
|
|
// Copyright 2021 Signal Messenger, LLC
|
2021-02-23 20:34:28 +00:00
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
2021-10-26 19:15:33 +00:00
|
|
|
import type { ReactNode, FunctionComponent } from 'react';
|
2022-01-31 22:45:56 +00:00
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
2021-02-23 20:34:28 +00:00
|
|
|
import classNames from 'classnames';
|
|
|
|
import { isBoolean, isNumber } from 'lodash';
|
|
|
|
|
|
|
|
import { Avatar, AvatarSize } from '../Avatar';
|
2021-11-02 23:01:13 +00:00
|
|
|
import type { BadgeType } from '../../badges/types';
|
2021-02-23 20:34:28 +00:00
|
|
|
import { isConversationUnread } from '../../util/isConversationUnread';
|
|
|
|
import { cleanId } from '../_util';
|
2021-11-02 23:01:13 +00:00
|
|
|
import type { LocalizerType, ThemeType } from '../../types/Util';
|
2021-10-26 19:15:33 +00:00
|
|
|
import type { ConversationType } from '../../state/ducks/conversations';
|
2021-11-12 01:17:29 +00:00
|
|
|
import { Spinner } from '../Spinner';
|
2022-01-31 22:45:56 +00:00
|
|
|
import { Time } from '../Time';
|
2022-01-26 23:05:26 +00:00
|
|
|
import { formatDateTimeShort } from '../../util/timestamp';
|
2022-01-31 22:45:56 +00:00
|
|
|
import * as durations from '../../util/durations';
|
2023-01-13 00:24:59 +00:00
|
|
|
import { UUID } from '../../types/UUID';
|
2021-02-23 20:34:28 +00:00
|
|
|
|
|
|
|
const BASE_CLASS_NAME =
|
|
|
|
'module-conversation-list__item--contact-or-conversation';
|
2021-12-09 18:50:13 +00:00
|
|
|
const AVATAR_CONTAINER_CLASS_NAME = `${BASE_CLASS_NAME}__avatar-container`;
|
2021-02-23 20:34:28 +00:00
|
|
|
const CONTENT_CLASS_NAME = `${BASE_CLASS_NAME}__content`;
|
|
|
|
const HEADER_CLASS_NAME = `${CONTENT_CLASS_NAME}__header`;
|
2021-10-14 15:48:48 +00:00
|
|
|
export const HEADER_NAME_CLASS_NAME = `${HEADER_CLASS_NAME}__name`;
|
|
|
|
export const HEADER_CONTACT_NAME_CLASS_NAME = `${HEADER_NAME_CLASS_NAME}__contact-name`;
|
2021-02-23 20:34:28 +00:00
|
|
|
export const DATE_CLASS_NAME = `${HEADER_CLASS_NAME}__date`;
|
2021-03-12 22:04:56 +00:00
|
|
|
const MESSAGE_CLASS_NAME = `${CONTENT_CLASS_NAME}__message`;
|
2021-02-23 20:34:28 +00:00
|
|
|
export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`;
|
2022-08-10 18:37:19 +00:00
|
|
|
const CHECKBOX_CONTAINER_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox--container`;
|
2021-03-03 20:09:58 +00:00
|
|
|
const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`;
|
2023-01-25 23:51:08 +00:00
|
|
|
export const SPINNER_CLASS_NAME = `${BASE_CLASS_NAME}__spinner`;
|
2021-02-23 20:34:28 +00:00
|
|
|
|
|
|
|
type PropsType = {
|
2021-03-03 20:09:58 +00:00
|
|
|
checked?: boolean;
|
2021-02-23 20:34:28 +00:00
|
|
|
conversationType: 'group' | 'direct';
|
2021-03-03 20:09:58 +00:00
|
|
|
disabled?: boolean;
|
2021-02-23 20:34:28 +00:00
|
|
|
headerDate?: number;
|
|
|
|
headerName: ReactNode;
|
|
|
|
id?: string;
|
2021-04-30 19:40:25 +00:00
|
|
|
i18n: LocalizerType;
|
2021-02-23 20:34:28 +00:00
|
|
|
isNoteToSelf?: boolean;
|
|
|
|
isSelected: boolean;
|
2021-11-12 01:17:29 +00:00
|
|
|
isUsernameSearchResult?: boolean;
|
2021-02-23 20:34:28 +00:00
|
|
|
markedUnread?: boolean;
|
|
|
|
messageId?: string;
|
|
|
|
messageStatusIcon?: ReactNode;
|
|
|
|
messageText?: ReactNode;
|
2021-10-12 23:59:08 +00:00
|
|
|
messageTextIsAlwaysFullSize?: boolean;
|
2021-03-03 20:09:58 +00:00
|
|
|
onClick?: () => void;
|
2021-11-12 01:17:29 +00:00
|
|
|
shouldShowSpinner?: boolean;
|
2021-02-23 20:34:28 +00:00
|
|
|
unreadCount?: number;
|
2022-09-26 16:24:52 +00:00
|
|
|
avatarSize?: AvatarSize;
|
2023-01-13 00:24:59 +00:00
|
|
|
testId?: string;
|
2021-04-30 19:40:25 +00:00
|
|
|
} & Pick<
|
|
|
|
ConversationType,
|
|
|
|
| 'acceptedMessageRequest'
|
|
|
|
| 'avatarPath'
|
|
|
|
| 'color'
|
2023-01-13 00:24:59 +00:00
|
|
|
| 'groupId'
|
2021-04-30 19:40:25 +00:00
|
|
|
| 'isMe'
|
|
|
|
| 'markedUnread'
|
|
|
|
| 'phoneNumber'
|
|
|
|
| 'profileName'
|
|
|
|
| 'sharedGroupNames'
|
|
|
|
| 'title'
|
|
|
|
| 'unblurredAvatarPath'
|
2023-01-13 00:24:59 +00:00
|
|
|
| 'uuid'
|
2021-11-17 21:11:21 +00:00
|
|
|
> &
|
|
|
|
(
|
|
|
|
| { badge?: undefined; theme?: ThemeType }
|
|
|
|
| { badge: BadgeType; theme: ThemeType }
|
|
|
|
);
|
2021-02-23 20:34:28 +00:00
|
|
|
|
2021-11-11 22:43:05 +00:00
|
|
|
export const BaseConversationListItem: FunctionComponent<PropsType> =
|
2021-12-01 17:24:00 +00:00
|
|
|
React.memo(function BaseConversationListItem(props) {
|
|
|
|
const {
|
|
|
|
acceptedMessageRequest,
|
|
|
|
avatarPath,
|
2022-09-26 16:24:52 +00:00
|
|
|
avatarSize,
|
2021-12-01 17:24:00 +00:00
|
|
|
checked,
|
|
|
|
color,
|
|
|
|
conversationType,
|
|
|
|
disabled,
|
2023-01-13 00:24:59 +00:00
|
|
|
groupId,
|
2021-12-01 17:24:00 +00:00
|
|
|
headerDate,
|
|
|
|
headerName,
|
|
|
|
i18n,
|
|
|
|
id,
|
|
|
|
isMe,
|
|
|
|
isNoteToSelf,
|
|
|
|
isUsernameSearchResult,
|
|
|
|
isSelected,
|
|
|
|
markedUnread,
|
|
|
|
messageStatusIcon,
|
|
|
|
messageText,
|
|
|
|
messageTextIsAlwaysFullSize,
|
|
|
|
onClick,
|
|
|
|
phoneNumber,
|
|
|
|
profileName,
|
|
|
|
sharedGroupNames,
|
|
|
|
shouldShowSpinner,
|
2023-01-13 00:24:59 +00:00
|
|
|
testId: overrideTestId,
|
2021-12-01 17:24:00 +00:00
|
|
|
title,
|
|
|
|
unblurredAvatarPath,
|
|
|
|
unreadCount,
|
2023-01-13 00:24:59 +00:00
|
|
|
uuid,
|
2021-12-01 17:24:00 +00:00
|
|
|
} = props;
|
|
|
|
|
2021-09-17 23:48:57 +00:00
|
|
|
const identifier = id ? cleanId(id) : undefined;
|
2023-01-13 00:24:59 +00:00
|
|
|
const htmlId = useMemo(() => UUID.generate().toString(), []);
|
|
|
|
const testId = overrideTestId || groupId || uuid;
|
2021-02-23 20:34:28 +00:00
|
|
|
const isUnread = isConversationUnread({ markedUnread, unreadCount });
|
|
|
|
|
|
|
|
const isAvatarNoteToSelf = isBoolean(isNoteToSelf)
|
|
|
|
? isNoteToSelf
|
|
|
|
: Boolean(isMe);
|
|
|
|
|
2021-03-03 20:09:58 +00:00
|
|
|
const isCheckbox = isBoolean(checked);
|
|
|
|
|
2021-11-12 01:17:29 +00:00
|
|
|
let actionNode: ReactNode;
|
|
|
|
if (shouldShowSpinner) {
|
|
|
|
actionNode = (
|
2022-04-05 00:38:22 +00:00
|
|
|
<Spinner
|
|
|
|
size="20px"
|
|
|
|
svgSize="small"
|
|
|
|
moduleClassName={SPINNER_CLASS_NAME}
|
|
|
|
direction="on-progress-dialog"
|
|
|
|
/>
|
2021-11-12 01:17:29 +00:00
|
|
|
);
|
|
|
|
} else if (isCheckbox) {
|
2021-03-03 20:09:58 +00:00
|
|
|
let ariaLabel: string;
|
|
|
|
if (disabled) {
|
2021-09-17 23:48:57 +00:00
|
|
|
ariaLabel = i18n('cannotSelectContact', [title]);
|
2021-03-03 20:09:58 +00:00
|
|
|
} else if (checked) {
|
2021-09-17 23:48:57 +00:00
|
|
|
ariaLabel = i18n('deselectContact', [title]);
|
2021-03-03 20:09:58 +00:00
|
|
|
} else {
|
2021-09-17 23:48:57 +00:00
|
|
|
ariaLabel = i18n('selectContact', [title]);
|
2021-03-03 20:09:58 +00:00
|
|
|
}
|
2021-11-12 01:17:29 +00:00
|
|
|
actionNode = (
|
2022-08-10 18:37:19 +00:00
|
|
|
<div className={CHECKBOX_CONTAINER_CLASS_NAME}>
|
|
|
|
<input
|
|
|
|
aria-label={ariaLabel}
|
|
|
|
checked={checked}
|
|
|
|
className={CHECKBOX_CLASS_NAME}
|
|
|
|
disabled={disabled}
|
|
|
|
id={htmlId}
|
|
|
|
onChange={onClick}
|
|
|
|
onKeyDown={event => {
|
|
|
|
if (onClick && !disabled && event.key === 'Enter') {
|
|
|
|
onClick();
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
type="checkbox"
|
|
|
|
/>
|
|
|
|
</div>
|
2021-03-03 20:09:58 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
const contents = (
|
|
|
|
<>
|
2021-12-09 18:50:13 +00:00
|
|
|
<div className={AVATAR_CONTAINER_CLASS_NAME}>
|
|
|
|
<Avatar
|
|
|
|
acceptedMessageRequest={acceptedMessageRequest}
|
|
|
|
avatarPath={avatarPath}
|
|
|
|
color={color}
|
|
|
|
conversationType={conversationType}
|
|
|
|
noteToSelf={isAvatarNoteToSelf}
|
|
|
|
searchResult={isUsernameSearchResult}
|
|
|
|
i18n={i18n}
|
|
|
|
isMe={isMe}
|
|
|
|
phoneNumber={phoneNumber}
|
|
|
|
profileName={profileName}
|
|
|
|
title={title}
|
|
|
|
sharedGroupNames={sharedGroupNames}
|
2022-09-26 16:24:52 +00:00
|
|
|
size={avatarSize ?? AvatarSize.FORTY_EIGHT}
|
2021-12-09 18:50:13 +00:00
|
|
|
unblurredAvatarPath={unblurredAvatarPath}
|
|
|
|
// This is here to appease the type checker.
|
|
|
|
{...(props.badge
|
|
|
|
? { badge: props.badge, theme: props.theme }
|
|
|
|
: { badge: undefined })}
|
|
|
|
/>
|
|
|
|
<UnreadIndicator count={unreadCount} isUnread={isUnread} />
|
|
|
|
</div>
|
2021-03-03 20:09:58 +00:00
|
|
|
<div
|
|
|
|
className={classNames(
|
|
|
|
CONTENT_CLASS_NAME,
|
|
|
|
disabled && `${CONTENT_CLASS_NAME}--disabled`
|
|
|
|
)}
|
|
|
|
>
|
2021-02-23 20:34:28 +00:00
|
|
|
<div className={HEADER_CLASS_NAME}>
|
|
|
|
<div className={`${HEADER_CLASS_NAME}__name`}>{headerName}</div>
|
2022-01-26 23:05:26 +00:00
|
|
|
<Timestamp timestamp={headerDate} i18n={i18n} />
|
2021-02-23 20:34:28 +00:00
|
|
|
</div>
|
2021-10-12 23:59:08 +00:00
|
|
|
{messageText || isUnread ? (
|
2021-02-23 20:34:28 +00:00
|
|
|
<div className={MESSAGE_CLASS_NAME}>
|
2021-10-12 23:59:08 +00:00
|
|
|
{Boolean(messageText) && (
|
|
|
|
<div
|
|
|
|
dir="auto"
|
|
|
|
className={classNames(
|
|
|
|
MESSAGE_TEXT_CLASS_NAME,
|
|
|
|
messageTextIsAlwaysFullSize &&
|
|
|
|
`${MESSAGE_TEXT_CLASS_NAME}--always-full-size`
|
|
|
|
)}
|
|
|
|
>
|
|
|
|
{messageText}
|
|
|
|
</div>
|
|
|
|
)}
|
2021-02-23 20:34:28 +00:00
|
|
|
{messageStatusIcon}
|
2021-12-09 18:50:13 +00:00
|
|
|
<UnreadIndicator count={unreadCount} isUnread={isUnread} />
|
2021-02-23 20:34:28 +00:00
|
|
|
</div>
|
|
|
|
) : null}
|
|
|
|
</div>
|
2021-11-12 01:17:29 +00:00
|
|
|
{actionNode}
|
2021-03-03 20:09:58 +00:00
|
|
|
</>
|
|
|
|
);
|
|
|
|
|
|
|
|
const commonClassNames = classNames(BASE_CLASS_NAME, {
|
|
|
|
[`${BASE_CLASS_NAME}--is-selected`]: isSelected,
|
|
|
|
});
|
|
|
|
|
|
|
|
if (isCheckbox) {
|
|
|
|
return (
|
|
|
|
<label
|
|
|
|
className={classNames(
|
|
|
|
commonClassNames,
|
|
|
|
`${BASE_CLASS_NAME}--is-checkbox`,
|
|
|
|
{ [`${BASE_CLASS_NAME}--is-checkbox--disabled`]: disabled }
|
|
|
|
)}
|
2021-09-17 23:48:57 +00:00
|
|
|
data-id={identifier}
|
2023-01-13 00:24:59 +00:00
|
|
|
data-testid={testId}
|
2021-10-21 20:07:13 +00:00
|
|
|
htmlFor={htmlId}
|
2021-03-03 20:09:58 +00:00
|
|
|
// `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 } : {})}
|
|
|
|
>
|
|
|
|
{contents}
|
|
|
|
</label>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (onClick) {
|
|
|
|
return (
|
|
|
|
<button
|
2021-10-07 23:52:48 +00:00
|
|
|
aria-label={i18n('BaseConversationListItem__aria-label', { title })}
|
2021-03-03 20:09:58 +00:00
|
|
|
className={classNames(
|
|
|
|
commonClassNames,
|
|
|
|
`${BASE_CLASS_NAME}--is-button`
|
|
|
|
)}
|
2021-09-17 23:48:57 +00:00
|
|
|
data-id={identifier}
|
2023-01-13 00:24:59 +00:00
|
|
|
data-testid={testId}
|
2021-03-03 20:09:58 +00:00
|
|
|
disabled={disabled}
|
|
|
|
onClick={onClick}
|
|
|
|
type="button"
|
|
|
|
>
|
|
|
|
{contents}
|
|
|
|
</button>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
2023-01-13 00:24:59 +00:00
|
|
|
<div
|
|
|
|
className={commonClassNames}
|
|
|
|
data-id={identifier}
|
|
|
|
data-testid={testId}
|
|
|
|
>
|
2021-03-03 20:09:58 +00:00
|
|
|
{contents}
|
|
|
|
</div>
|
2021-02-23 20:34:28 +00:00
|
|
|
);
|
2021-11-11 22:43:05 +00:00
|
|
|
});
|
2021-10-05 23:46:51 +00:00
|
|
|
|
2022-01-26 23:05:26 +00:00
|
|
|
function Timestamp({
|
|
|
|
i18n,
|
|
|
|
timestamp,
|
|
|
|
}: Readonly<{ i18n: LocalizerType; timestamp?: number }>) {
|
2022-01-31 22:45:56 +00:00
|
|
|
const getText = useCallback(
|
|
|
|
() => (isNumber(timestamp) ? formatDateTimeShort(i18n, timestamp) : ''),
|
|
|
|
[i18n, timestamp]
|
|
|
|
);
|
|
|
|
|
|
|
|
const [text, setText] = useState(getText());
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
const update = () => setText(getText());
|
|
|
|
update();
|
|
|
|
const interval = setInterval(update, durations.MINUTE);
|
|
|
|
return () => {
|
|
|
|
clearInterval(interval);
|
|
|
|
};
|
|
|
|
}, [getText]);
|
|
|
|
|
2022-01-26 23:05:26 +00:00
|
|
|
if (!isNumber(timestamp)) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
2022-01-31 22:45:56 +00:00
|
|
|
<Time className={DATE_CLASS_NAME} timestamp={timestamp}>
|
|
|
|
{text}
|
|
|
|
</Time>
|
2022-01-26 23:05:26 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-12-09 18:50:13 +00:00
|
|
|
function UnreadIndicator({
|
|
|
|
count = 0,
|
|
|
|
isUnread,
|
|
|
|
}: Readonly<{ count?: number; isUnread: boolean }>) {
|
|
|
|
if (!isUnread) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2021-10-14 20:21:10 +00:00
|
|
|
return (
|
2023-01-25 17:27:59 +00:00
|
|
|
<div className={classNames(`${BASE_CLASS_NAME}__unread-indicator`)}>
|
|
|
|
{Boolean(count) && count}
|
2021-10-14 20:21:10 +00:00
|
|
|
</div>
|
|
|
|
);
|
2021-10-05 23:46:51 +00:00
|
|
|
}
|