// Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { MeasuredComponentProps } from 'react-measure'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import Measure from 'react-measure'; import { noop } from 'lodash'; import type { ConversationType } from '../state/ducks/conversations'; import type { LocalizerType } from '../types/Util'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { Row } from './ConversationList'; import type { StoryDistributionListWithMembersDataType } from '../types/Stories'; import type { UUIDStringType } from '../types/UUID'; import type { RenderModalPage, ModalPropsType } from './Modal'; import { Avatar, AvatarSize } from './Avatar'; import { Button, ButtonVariant } from './Button'; import { Checkbox } from './Checkbox'; import { ConfirmationDialog } from './ConfirmationDialog'; import { ContactPills } from './ContactPills'; import { ContactPill } from './ContactPill'; import { ConversationList, RowType } from './ConversationList'; import { Input } from './Input'; import { Intl } from './Intl'; import { MY_STORIES_ID, getStoryDistributionListName } from '../types/Stories'; import { PagedModal, ModalPage } from './Modal'; import { SearchInput } from './SearchInput'; import { StoryDistributionListName } from './StoryDistributionListName'; import { Theme } from '../util/theme'; import { ThemeType } from '../types/Util'; import { UUID } from '../types/UUID'; import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations'; import { isNotNil } from '../util/isNotNil'; import { shouldNeverBeCalled, asyncShouldNeverBeCalled, } from '../util/shouldNeverBeCalled'; export type PropsType = { candidateConversations: Array; distributionLists: Array; getPreferredBadge: PreferredBadgeSelectorType; hideStoriesSettings: () => unknown; i18n: LocalizerType; me: ConversationType; onDeleteList: (listId: string) => unknown; onDistributionListCreated: ( name: string, viewerUuids: Array ) => unknown; onHideMyStoriesFrom: (viewerUuids: Array) => unknown; onRemoveMember: (listId: string, uuid: UUIDStringType | undefined) => unknown; onRepliesNReactionsChanged: ( listId: string, allowsReplies: boolean ) => unknown; onViewersUpdated: ( listId: string, viewerUuids: Array ) => unknown; setMyStoriesToAllSignalConnections: () => unknown; toggleSignalConnectionsModal: () => unknown; }; export enum Page { DistributionLists = 'DistributionLists', AddViewer = 'AddViewer', ChooseViewers = 'ChooseViewers', NameStory = 'NameStory', HideStoryFrom = 'HideStoryFrom', } function filterConversations( conversations: ReadonlyArray, searchTerm: string ) { return filterAndSortConversationsByRecent( conversations, searchTerm, undefined ).filter(conversation => conversation.uuid); } const modalCommonProps: Pick = { hasXButton: true, moduleClassName: 'StoriesSettingsModal__modal', }; export const StoriesSettingsModal = ({ candidateConversations, distributionLists, getPreferredBadge, hideStoriesSettings, i18n, me, onDeleteList, onDistributionListCreated, onHideMyStoriesFrom, onRemoveMember, onRepliesNReactionsChanged, onViewersUpdated, setMyStoriesToAllSignalConnections, toggleSignalConnectionsModal, }: PropsType): JSX.Element => { const [listToEditId, setListToEditId] = useState( undefined ); const listToEdit = useMemo( () => distributionLists.find(x => x.id === listToEditId), [distributionLists, listToEditId] ); const [page, setPage] = useState(Page.DistributionLists); const [selectedContacts, setSelectedContacts] = useState< Array >([]); const resetChooseViewersScreen = useCallback(() => { setSelectedContacts([]); setPage(Page.DistributionLists); }, []); const [confirmDeleteListId, setConfirmDeleteListId] = useState< string | undefined >(); let modal: RenderModalPage | null; if (page !== Page.DistributionLists) { const isChoosingViewers = page === Page.ChooseViewers || page === Page.AddViewer; modal = onClose => ( { onDistributionListCreated(name, uuids); resetChooseViewersScreen(); }} onBackButtonClick={() => { if (page === Page.HideStoryFrom) { resetChooseViewersScreen(); } else if (page === Page.NameStory) { setPage(Page.ChooseViewers); } else if (isChoosingViewers) { resetChooseViewersScreen(); } else if (listToEdit) { setListToEditId(undefined); } }} onViewersUpdated={uuids => { if (listToEditId && page === Page.AddViewer) { onViewersUpdated(listToEditId, uuids); resetChooseViewersScreen(); } if (page === Page.ChooseViewers) { setPage(Page.NameStory); } if (page === Page.HideStoryFrom) { onHideMyStoriesFrom(uuids); resetChooseViewersScreen(); } }} selectedContacts={selectedContacts} setSelectedContacts={setSelectedContacts} /> ); } else if (listToEdit) { modal = onClose => ( setListToEditId(undefined)} onClose={onClose} /> ); } else { const privateStories = distributionLists.filter( list => list.id !== MY_STORIES_ID ); modal = onClose => (
{privateStories.map(list => ( ))}
); } return ( <> {modal} {confirmDeleteListId && ( { onDeleteList(confirmDeleteListId); setListToEditId(undefined); }, style: 'negative', text: i18n('delete'), }, ]} i18n={i18n} onClose={() => { setConfirmDeleteListId(undefined); }} > {i18n('StoriesSettings__delete-list--confirm')} )} ); }; type DistributionListSettingsModalPropsType = { i18n: LocalizerType; listToEdit: StoryDistributionListWithMembersDataType; setConfirmDeleteListId: (id: string) => unknown; setPage: (page: Page) => unknown; setSelectedContacts: (contacts: Array) => unknown; onBackButtonClick: (() => void) | undefined; onClose: () => void; } & Pick< PropsType, | 'getPreferredBadge' | 'onRemoveMember' | 'onRepliesNReactionsChanged' | 'setMyStoriesToAllSignalConnections' | 'toggleSignalConnectionsModal' >; export const DistributionListSettingsModal = ({ getPreferredBadge, i18n, listToEdit, onRemoveMember, onRepliesNReactionsChanged, onBackButtonClick, onClose, setConfirmDeleteListId, setMyStoriesToAllSignalConnections, setPage, setSelectedContacts, toggleSignalConnectionsModal, }: DistributionListSettingsModalPropsType): JSX.Element => { const [confirmRemoveMember, setConfirmRemoveMember] = useState< | undefined | { listId: string; title: string; uuid: UUIDStringType | undefined; } >(); const isMyStories = listToEdit.id === MY_STORIES_ID; const modalTitle = getStoryDistributionListName( i18n, listToEdit.id, listToEdit.name ); return ( {!isMyStories && ( <>

)}
{i18n('StoriesSettings__who-can-see')}
{isMyStories && ( { setPage(Page.HideStoryFrom); }} onClickOnlyShareWith={() => { setPage(Page.AddViewer); }} setSelectedContacts={setSelectedContacts} setMyStoriesToAllSignalConnections={ setMyStoriesToAllSignalConnections } toggleSignalConnectionsModal={toggleSignalConnectionsModal} /> )} {!isMyStories && ( <> {listToEdit.members.map(member => (
{member.title}
))} )}
{i18n('StoriesSettings__replies-reactions--title')}
onRepliesNReactionsChanged(listToEdit.id, value)} /> {!isMyStories && ( <>
)} {confirmRemoveMember && ( onRemoveMember( confirmRemoveMember.listId, confirmRemoveMember.uuid ), style: 'negative', text: i18n('StoriesSettings__remove--action'), }, ]} i18n={i18n} onClose={() => { setConfirmRemoveMember(undefined); }} title={i18n('StoriesSettings__remove--title', [ confirmRemoveMember.title, ])} > {i18n('StoriesSettings__remove--body')} )}
); }; type EditMyStoriesPrivacyPropsType = { hasDisclaimerAbove?: boolean; i18n: LocalizerType; learnMore: string; myStories: StoryDistributionListWithMembersDataType; onClickExclude: () => unknown; onClickOnlyShareWith: () => unknown; setSelectedContacts: (contacts: Array) => unknown; } & Pick< PropsType, 'setMyStoriesToAllSignalConnections' | 'toggleSignalConnectionsModal' >; export const EditMyStoriesPrivacy = ({ hasDisclaimerAbove, i18n, learnMore, myStories, onClickExclude, onClickOnlyShareWith, setSelectedContacts, setMyStoriesToAllSignalConnections, toggleSignalConnectionsModal, }: EditMyStoriesPrivacyPropsType): JSX.Element => { const disclaimerElement = (
{i18n('StoriesSettings__mine__disclaimer--learn-more')} ), }} i18n={i18n} id={learnMore} />
); return ( <> {hasDisclaimerAbove && disclaimerElement} { setMyStoriesToAllSignalConnections(); }} /> 0} description={i18n('StoriesSettings__mine__exclude--description', [ myStories.isBlockList ? String(myStories.members.length) : '0', ])} isRadio label={i18n('StoriesSettings__mine__exclude--label')} moduleClassName="StoriesSettingsModal__checkbox" name="share" onChange={noop} onClick={() => { if (myStories.isBlockList) { setSelectedContacts(myStories.members); } onClickExclude(); }} /> 0} description={ !myStories.isBlockList && myStories.members.length ? i18n('StoriesSettings__mine__only--description--people', [ String(myStories.members.length), ]) : i18n('StoriesSettings__mine__only--description') } isRadio label={i18n('StoriesSettings__mine__only--label')} moduleClassName="StoriesSettingsModal__checkbox" name="share" onChange={noop} onClick={() => { if (!myStories.isBlockList) { setSelectedContacts(myStories.members); } onClickOnlyShareWith(); }} /> {!hasDisclaimerAbove && disclaimerElement} ); }; type EditDistributionListModalPropsType = { onCreateList: (name: string, viewerUuids: Array) => unknown; onViewersUpdated: (viewerUuids: Array) => unknown; page: | Page.AddViewer | Page.ChooseViewers | Page.HideStoryFrom | Page.NameStory; selectedContacts: Array; onClose: () => unknown; setSelectedContacts: (contacts: Array) => unknown; onBackButtonClick: () => void; } & Pick; /** * * @param param0 * @returns */ export const EditDistributionListModal = ({ candidateConversations, getPreferredBadge, i18n, onCreateList, onViewersUpdated, page, onClose, selectedContacts, setSelectedContacts, onBackButtonClick, }: EditDistributionListModalPropsType): JSX.Element => { const [storyName, setStoryName] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const normalizedSearchTerm = searchTerm.trim(); const [filteredConversations, setFilteredConversations] = useState( filterConversations(candidateConversations, normalizedSearchTerm) ); useEffect(() => { const timeout = setTimeout(() => { setFilteredConversations( filterConversations(candidateConversations, normalizedSearchTerm) ); }, 200); return () => { clearTimeout(timeout); }; }, [candidateConversations, normalizedSearchTerm, setFilteredConversations]); const contactLookup = useMemo(() => { const map = new Map(); candidateConversations.forEach(contact => { map.set(contact.id, contact); }); return map; }, [candidateConversations]); const selectedConversationUuids: Set = useMemo( () => new Set(selectedContacts.map(contact => contact.uuid).filter(isNotNil)), [selectedContacts] ); const toggleSelectedConversation = useCallback( (conversationId: string) => { let removeContact = false; const nextSelectedContacts = selectedContacts.filter(contact => { if (contact.id === conversationId) { removeContact = true; return false; } return true; }); if (removeContact) { setSelectedContacts(nextSelectedContacts); return; } const selectedContact = contactLookup.get(conversationId); if (selectedContact) { setSelectedContacts([...nextSelectedContacts, selectedContact]); } }, [contactLookup, selectedContacts, setSelectedContacts] ); const isChoosingViewers = page === Page.ChooseViewers || page === Page.AddViewer; if (page === Page.NameStory) { const footer = ( ); return (
{i18n('StoriesSettings__who-can-see')}
{selectedContacts.map(contact => (
{contact.title}
))} ); } const rowCount = filteredConversations.length; const getRow = (index: number): undefined | Row => { const contact = filteredConversations[index]; if (!contact || !contact.uuid) { return undefined; } const isSelected = selectedConversationUuids.has(UUID.cast(contact.uuid)); return { type: RowType.ContactCheckbox, contact, isChecked: isSelected, }; }; let footer: JSX.Element | undefined; if (isChoosingViewers) { footer = ( ); } else if (page === Page.HideStoryFrom) { footer = ( ); } return ( { setSearchTerm(event.target.value); }} value={searchTerm} /> {selectedContacts.length ? ( {selectedContacts.map(contact => ( toggleSelectedConversation(contact.id)} /> ))} ) : undefined} {candidateConversations.length ? ( {({ contentRect, measureRef }: MeasuredComponentProps) => (
{ toggleSelectedConversation(conversationId); }} lookupConversationWithoutUuid={asyncShouldNeverBeCalled} showConversation={shouldNeverBeCalled} showUserNotFoundModal={shouldNeverBeCalled} setIsFetchingUUID={shouldNeverBeCalled} onSelectConversation={shouldNeverBeCalled} renderMessageSearchResult={() => { shouldNeverBeCalled(); return
; }} rowCount={rowCount} shouldRecomputeRowHeights={false} showChooseGroupMembers={shouldNeverBeCalled} theme={ThemeType.dark} />
)} ) : (
{i18n('noContactsFound')}
)} ); };