// Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only 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 { LocalizerType, ReplacementValuesType, ThemeType, } from '../types/Util'; import { ToastType } from '../types/Toast'; import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations'; import { ConfirmationDialog } from './ConfirmationDialog'; 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; theme: ThemeType; contact: Pick; candidateConversations: ReadonlyArray; regionCode: string | undefined; }; type DispatchProps = { toggleAddUserToAnotherGroupModal: (contactId?: string) => void; addMembersToGroup: ( conversationId: string, contactIds: Array, opts: { onSuccess?: () => unknown; onFailure?: () => unknown; } ) => void; showToast: (toastType: ToastType, parameters?: ReplacementValuesType) => void; }; export type Props = OwnProps & DispatchProps; export function AddUserToAnotherGroupModal({ i18n, contact, toggleAddUserToAnotherGroupModal, addMembersToGroup, showToast, candidateConversations, regionCode, }: Props): JSX.Element | null { const [searchTerm, setSearchTerm] = React.useState(''); const [filteredConversations, setFilteredConversations] = React.useState( filterAndSortConversationsByRecent(candidateConversations, '', undefined) ); const [selectedGroupId, setSelectedGroupId] = React.useState< undefined | string >(undefined); const groupLookup: Map = React.useMemo(() => { const map = new Map(); candidateConversations.forEach(conversation => { map.set(conversation.id, conversation); }); return map; }, [candidateConversations]); const [inputRef] = useRestoreFocus(); const normalizedSearchTerm = searchTerm.trim(); React.useEffect(() => { const timeout = setTimeout(() => { setFilteredConversations( filterAndSortConversationsByRecent( candidateConversations, normalizedSearchTerm, regionCode ) ); }, 200); return () => { clearTimeout(timeout); }; }, [ candidateConversations, normalizedSearchTerm, setFilteredConversations, regionCode, ]); const selectedGroup = selectedGroupId ? groupLookup.get(selectedGroupId) : undefined; const handleSearchInputChange = React.useCallback( (event: React.ChangeEvent) => { setSearchTerm(event.target.value); }, [setSearchTerm] ); const handleGetRow = React.useCallback( (idx: number): GroupListItemConversationType => { const convo = filteredConversations[idx]; // these are always populated in the case of a group const memberships = convo.memberships ?? []; const pendingApprovalMemberships = convo.pendingApprovalMemberships ?? []; const pendingMemberships = convo.pendingMemberships ?? []; const membersCount = convo.membersCount ?? 0; let disabledReason; if (memberships.some(c => c.uuid === contact.uuid)) { disabledReason = DisabledReason.AlreadyMember; } else if ( pendingApprovalMemberships.some(c => c.uuid === contact.uuid) || pendingMemberships.some(c => c.uuid === contact.uuid) ) { disabledReason = DisabledReason.Pending; } return { ...pick(convo, 'id', 'avatarPath', 'title', 'unblurredAvatarPath'), memberships, membersCount, disabledReason, }; }, [filteredConversations, contact] ); const renderGroupListItem = useCallback( ({ key, index, style }: ListRowProps) => { const group = handleGetRow(index); return (
); }, [i18n, handleGetRow] ); const handleCalculateRowHeight = useCallback( () => ListTile.heightCompact, [] ); return ( <> {!selectedGroup && (
{({ 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 (
); }}
)} {selectedGroupId && selectedGroup && ( setSelectedGroupId(undefined)} actions={[ { text: i18n('AddUserToAnotherGroupModal__confirm-add'), style: 'affirmative', action: () => { showToast(ToastType.AddingUserToGroup, { contact: contact.title, }); addMembersToGroup(selectedGroupId, [contact.id], { onSuccess: () => showToast(ToastType.UserAddedToGroup, { contact: contact.title, group: selectedGroup.title, }), }); toggleAddUserToAnotherGroupModal(undefined); }, }, ]} > {i18n('AddUserToAnotherGroupModal__confirm-message', { contact: contact.title, group: selectedGroup.title, })} )} ); }