// Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode, FunctionComponent } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; import { isBoolean, isNumber } from 'lodash'; import { v4 as generateUuid } from 'uuid'; import { Avatar, AvatarSize } from '../Avatar'; import type { BadgeType } from '../../badges/types'; import { isConversationUnread } from '../../util/isConversationUnread'; import { cleanId } from '../_util'; import type { LocalizerType, ThemeType } from '../../types/Util'; import type { ConversationType } from '../../state/ducks/conversations'; import { Spinner } from '../Spinner'; import { Time } from '../Time'; import { formatDateTimeShort } from '../../util/timestamp'; import * as durations from '../../util/durations'; const BASE_CLASS_NAME = 'module-conversation-list__item--contact-or-conversation'; const AVATAR_CONTAINER_CLASS_NAME = `${BASE_CLASS_NAME}__avatar-container`; const CONTENT_CLASS_NAME = `${BASE_CLASS_NAME}__content`; const HEADER_CLASS_NAME = `${CONTENT_CLASS_NAME}__header`; export const HEADER_NAME_CLASS_NAME = `${HEADER_CLASS_NAME}__name`; export const HEADER_CONTACT_NAME_CLASS_NAME = `${HEADER_NAME_CLASS_NAME}__contact-name`; export const DATE_CLASS_NAME = `${HEADER_CLASS_NAME}__date`; 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`; export const SPINNER_CLASS_NAME = `${BASE_CLASS_NAME}__spinner`; type PropsType = { buttonAriaLabel?: string; checked?: boolean; conversationType: 'group' | 'direct'; disabled?: boolean; headerDate?: number; headerName: ReactNode; id?: string; i18n: LocalizerType; isNoteToSelf?: boolean; isSelected: boolean; isUsernameSearchResult?: boolean; markedUnread?: boolean; messageId?: string; messageStatusIcon?: ReactNode; messageText?: ReactNode; messageTextIsAlwaysFullSize?: boolean; onClick?: () => void; shouldShowSpinner?: boolean; unreadCount?: number; unreadMentionsCount?: number; avatarSize?: AvatarSize; testId?: string; } & Pick< ConversationType, | 'acceptedMessageRequest' | 'avatarPath' | 'color' | 'groupId' | 'isMe' | 'markedUnread' | 'phoneNumber' | 'profileName' | 'sharedGroupNames' | 'title' | 'unblurredAvatarPath' | 'serviceId' > & ( | { badge?: undefined; theme?: ThemeType } | { badge: BadgeType; theme: ThemeType } ); export const BaseConversationListItem: FunctionComponent = React.memo(function BaseConversationListItem(props) { const { acceptedMessageRequest, avatarPath, avatarSize, buttonAriaLabel, checked, color, conversationType, disabled, groupId, headerDate, headerName, i18n, id, isMe, isNoteToSelf, isUsernameSearchResult, isSelected, markedUnread, messageStatusIcon, messageText, messageTextIsAlwaysFullSize, onClick, phoneNumber, profileName, sharedGroupNames, shouldShowSpinner, testId: overrideTestId, title, unblurredAvatarPath, unreadCount, unreadMentionsCount, serviceId, } = props; const identifier = id ? cleanId(id) : undefined; const htmlId = useMemo(() => generateUuid(), []); const testId = overrideTestId || groupId || serviceId; const isUnread = isConversationUnread({ markedUnread, unreadCount }); const isAvatarNoteToSelf = isBoolean(isNoteToSelf) ? isNoteToSelf : Boolean(isMe); const isCheckbox = isBoolean(checked); let actionNode: ReactNode; if (shouldShowSpinner) { actionNode = ( ); } else if (isCheckbox) { let ariaLabel: string; if (disabled) { ariaLabel = i18n('icu:cannotSelectContact', { name: title, }); } else if (checked) { ariaLabel = i18n('icu:deselectContact', { name: title, }); } else { ariaLabel = i18n('icu:selectContact', { name: title, }); } actionNode = (
{ if (onClick && !disabled && event.key === 'Enter') { onClick(); } }} type="checkbox" />
); } const unreadIndicators = (() => { if (!isUnread) { return null; } return (
{unreadMentionsCount ? ( ) : null} {unreadCount ? ( ) : ( )}
); })(); const contents = ( <>
{unreadIndicators}
{headerName}
{messageText || isUnread ? (
{Boolean(messageText) && (
{messageText}
)} {messageStatusIcon} {unreadIndicators}
) : null}
{actionNode} ); const commonClassNames = classNames(BASE_CLASS_NAME, { [`${BASE_CLASS_NAME}--is-selected`]: isSelected, }); if (isCheckbox) { return ( ); } if (onClick) { return ( ); } return (
{contents}
); }); function Timestamp({ i18n, timestamp, }: Readonly<{ i18n: LocalizerType; timestamp?: number }>) { 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]); if (!isNumber(timestamp)) { return null; } return ( ); } enum UnreadIndicatorVariant { MARKED_UNREAD = 'marked-unread', UNREAD_MESSAGES = 'unread-messages', UNREAD_MENTIONS = 'unread-mentions', } type UnreadIndicatorPropsType = | { variant: UnreadIndicatorVariant.MARKED_UNREAD; } | { variant: UnreadIndicatorVariant.UNREAD_MESSAGES; count: number; } | { variant: UnreadIndicatorVariant.UNREAD_MENTIONS }; function UnreadIndicator(props: UnreadIndicatorPropsType) { let content: React.ReactNode; switch (props.variant) { case UnreadIndicatorVariant.MARKED_UNREAD: content = null; break; case UnreadIndicatorVariant.UNREAD_MESSAGES: content = props.count > 0 && props.count; break; case UnreadIndicatorVariant.UNREAD_MENTIONS: content = (
); break; default: throw new Error('Unexpected variant'); } return (
{content}
); }