// Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { MeasuredComponentProps } from 'react-measure'; import type { ReactNode } from 'react'; 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_STORY_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'; import { useConfirmDiscard } from '../hooks/useConfirmDiscard'; import { getGroupMemberships } from '../util/getGroupMemberships'; import { strictAssert } from '../util/assert'; export type PropsType = { candidateConversations: Array; distributionLists: Array; groupStories: Array; signalConnections: Array; getPreferredBadge: PreferredBadgeSelectorType; hideStoriesSettings: () => unknown; i18n: LocalizerType; me: ConversationType; onDeleteList: (listId: string) => unknown; toggleGroupsForStorySend: (groupIds: Array) => unknown; onDistributionListCreated: ( name: string, viewerUuids: Array ) => Promise; onHideMyStoriesFrom: (viewerUuids: Array) => unknown; onRemoveMembers: (listId: string, uuids: Array) => unknown; onRepliesNReactionsChanged: ( listId: string, allowsReplies: boolean ) => unknown; onViewersUpdated: ( listId: string, viewerUuids: Array ) => unknown; setMyStoriesToAllSignalConnections: () => unknown; storyViewReceiptsEnabled: boolean; toggleSignalConnectionsModal: () => unknown; toggleStoriesView: () => void; setStoriesDisabled: (value: boolean) => void; getConversationByUuid: (uuid: UUIDStringType) => ConversationType | undefined; }; 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 function getListViewers( list: StoryDistributionListWithMembersDataType, i18n: LocalizerType, signalConnections: Array ): string { let memberCount = list.members.length; if (list.id === MY_STORY_ID && list.isBlockList) { memberCount = list.isBlockList ? signalConnections.length - list.members.length : signalConnections.length; } return i18n('icu:StoriesSettings__viewers', { count: memberCount }); } export function getI18nForMyStory( list: StoryDistributionListWithMembersDataType, i18n: LocalizerType ): string { if (list.members.length === 0) { return i18n('StoriesSettings__mine__all--label'); } if (!list.isBlockList) { return i18n('SendStoryModal__only-share-with'); } return i18n('StoriesSettings__mine__all--label'); } type DistributionListItemProps = { i18n: LocalizerType; distributionList: StoryDistributionListWithMembersDataType; me: ConversationType; signalConnections: Array; onSelectItemToEdit(id: UUIDStringType): void; }; function DistributionListItem({ i18n, distributionList, me, signalConnections, onSelectItemToEdit, }: DistributionListItemProps) { const isMyStory = distributionList.id === MY_STORY_ID; return ( ); } type GroupStoryItemProps = { i18n: LocalizerType; groupStory: ConversationType; onSelectGroupToView(id: string): void; }; function GroupStoryItem({ i18n, groupStory, onSelectGroupToView, }: GroupStoryItemProps) { return ( ); } export function StoriesSettingsModal({ candidateConversations, distributionLists, groupStories, signalConnections, getPreferredBadge, hideStoriesSettings, i18n, me, onDeleteList, toggleGroupsForStorySend, onDistributionListCreated, onHideMyStoriesFrom, onRemoveMembers, onRepliesNReactionsChanged, onViewersUpdated, setMyStoriesToAllSignalConnections, storyViewReceiptsEnabled, toggleSignalConnectionsModal, toggleStoriesView, setStoriesDisabled, getConversationByUuid, }: PropsType): JSX.Element { const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard(i18n); const [listToEditId, setListToEditId] = useState( undefined ); const listToEdit = useMemo( () => distributionLists.find(x => x.id === listToEditId), [distributionLists, listToEditId] ); const [groupToViewId, setGroupToViewId] = useState(null); const groupToView = useMemo(() => { return groupStories.find(group => { return group.id === groupToViewId; }); }, [groupStories, groupToViewId]); const [page, setPage] = useState(Page.DistributionLists); const [selectedContacts, setSelectedContacts] = useState< Array >([]); const resetChooseViewersScreen = useCallback(() => { setSelectedContacts([]); setPage(Page.DistributionLists); }, []); const [confirmDeleteList, setConfirmDeleteList] = useState< { id: string; name: string } | undefined >(); const [confirmRemoveGroup, setConfirmRemoveGroup] = useState<{ id: string; title: string; } | null>(null); let modal: RenderModalPage | null; if (page !== Page.DistributionLists) { const isChoosingViewers = page === Page.ChooseViewers || page === Page.AddViewer; modal = onClose => ( { void onDistributionListCreated(name, uuids); resetChooseViewersScreen(); }} onBackButtonClick={() => confirmDiscardIf(selectedContacts.length > 0, () => { if (page === Page.HideStoryFrom) { resetChooseViewersScreen(); } else if (page === Page.NameStory) { setPage(Page.ChooseViewers); } else if (isChoosingViewers) { resetChooseViewersScreen(); } else if (listToEdit) { setListToEditId(undefined); } else if (groupToView) { setGroupToViewId(null); } }) } 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 = handleClose => ( setListToEditId(undefined)} onClose={handleClose} /> ); } else if (groupToView) { modal = onClose => ( setGroupToViewId(null)} getConversationByUuid={getConversationByUuid} onRemoveGroup={group => { setConfirmRemoveGroup({ id: group.id, title: group.title, }); }} /> ); } else { modal = onClose => (

{i18n('icu:StoriesSettings__description')}

{i18n('icu:StoriesSettings__my_stories')}

{distributionLists.map(distributionList => { return ( ); })} {groupStories.map(groupStory => { return ( ); })}

{i18n('Stories__settings-toggle--description')}

); } return ( <> {!confirmDiscardModal && ( confirmDiscardIf(selectedContacts.length > 0, hideStoriesSettings) } > {modal} )} {confirmDeleteList && ( { onDeleteList(confirmDeleteList.id); setListToEditId(undefined); }, style: 'negative', text: i18n('delete'), }, ]} i18n={i18n} onClose={() => { setConfirmDeleteList(undefined); }} theme={Theme.Dark} > {i18n('StoriesSettings__delete-list--confirm', [ confirmDeleteList.name, ])} )} {confirmRemoveGroup != null && ( { toggleGroupsForStorySend([confirmRemoveGroup.id]); setConfirmRemoveGroup(null); setGroupToViewId(null); }, style: 'negative', text: i18n('delete'), }, ]} i18n={i18n} onClose={() => { setConfirmRemoveGroup(null); }} theme={Theme.Dark} > {i18n('icu:StoriesSettings__remove_group--confirm', { groupTitle: confirmRemoveGroup.title, })} )} {confirmDiscardModal} ); } type DistributionListSettingsModalPropsType = { i18n: LocalizerType; listToEdit: StoryDistributionListWithMembersDataType; signalConnectionsCount: number; setConfirmDeleteList: (_: { id: string; name: string }) => unknown; setPage: (page: Page) => unknown; setSelectedContacts: (contacts: Array) => unknown; onBackButtonClick: (() => void) | undefined; onClose: () => void; } & Pick< PropsType, | 'getPreferredBadge' | 'onRemoveMembers' | 'onRepliesNReactionsChanged' | 'setMyStoriesToAllSignalConnections' | 'toggleSignalConnectionsModal' >; export function DistributionListSettingsModal({ getPreferredBadge, i18n, listToEdit, onRemoveMembers, onRepliesNReactionsChanged, onBackButtonClick, onClose, setConfirmDeleteList, setMyStoriesToAllSignalConnections, setPage, setSelectedContacts, toggleSignalConnectionsModal, signalConnectionsCount, }: DistributionListSettingsModalPropsType): JSX.Element { const [confirmRemoveMember, setConfirmRemoveMember] = useState< | undefined | { listId: string; title: string; uuid: UUIDStringType; } >(); const isMyStory = listToEdit.id === MY_STORY_ID; const modalTitle = getStoryDistributionListName( i18n, listToEdit.id, listToEdit.name ); return ( {!isMyStory && ( <>

)}
{i18n('StoriesSettings__who-can-see')}
{isMyStory && ( { setPage(Page.HideStoryFrom); }} onClickOnlyShareWith={() => { setPage(Page.AddViewer); }} setSelectedContacts={setSelectedContacts} setMyStoriesToAllSignalConnections={ setMyStoriesToAllSignalConnections } toggleSignalConnectionsModal={toggleSignalConnectionsModal} signalConnectionsCount={signalConnectionsCount} /> )} {!isMyStory && ( <> {listToEdit.members.map(member => (
{member.title}
))} )}
{i18n('StoriesSettings__replies-reactions--title')}
onRepliesNReactionsChanged(listToEdit.id, value)} /> {!isMyStory && ( <>
)} {confirmRemoveMember && ( onRemoveMembers(confirmRemoveMember.listId, [ confirmRemoveMember.uuid, ]), style: 'negative', text: i18n('StoriesSettings__remove--action'), }, ]} i18n={i18n} onClose={() => { setConfirmRemoveMember(undefined); }} theme={Theme.Dark} title={i18n('StoriesSettings__remove--title', [ confirmRemoveMember.title, ])} > {i18n('StoriesSettings__remove--body')} )}
); } type CheckboxRenderProps = { checkboxNode: ReactNode; labelNode: ReactNode; descriptionNode: ReactNode; }; function CheckboxRender({ checkboxNode, labelNode, descriptionNode, }: CheckboxRenderProps) { return ( <> {checkboxNode}
{labelNode}
{descriptionNode}
); } type EditMyStoryPrivacyPropsType = { hasDisclaimerAbove?: boolean; i18n: LocalizerType; learnMore: string; myStories: StoryDistributionListWithMembersDataType; onClickExclude: () => unknown; onClickOnlyShareWith: () => unknown; setSelectedContacts: (contacts: Array) => unknown; signalConnectionsCount: number; } & Pick< PropsType, 'setMyStoriesToAllSignalConnections' | 'toggleSignalConnectionsModal' >; export function EditMyStoryPrivacy({ hasDisclaimerAbove, i18n, learnMore, myStories, onClickExclude, onClickOnlyShareWith, setSelectedContacts, setMyStoriesToAllSignalConnections, toggleSignalConnectionsModal, signalConnectionsCount, }: EditMyStoryPrivacyPropsType): JSX.Element { const disclaimerElement = (
{i18n('StoriesSettings__mine__disclaimer--learn-more')} ), }} i18n={i18n} id={learnMore} />
); return ( <> {hasDisclaimerAbove && disclaimerElement} { setMyStoriesToAllSignalConnections(); }} > {({ checkboxNode, labelNode, checked }) => { return ( {i18n('icu:StoriesSettings__viewers', { count: signalConnectionsCount, })} ) } /> ); }} 0} isRadio label={i18n('StoriesSettings__mine__exclude--label')} moduleClassName="StoriesSettingsModal__checkbox" name="share" onChange={noop} onClick={() => { if (myStories.isBlockList) { setSelectedContacts(myStories.members); } onClickExclude(); }} > {({ checkboxNode, labelNode, checked }) => { return ( {i18n('icu:StoriesSettings__viewers', { count: myStories.members.length, })} ) } /> ); }} 0} isRadio label={i18n('StoriesSettings__mine__only--label')} moduleClassName="StoriesSettingsModal__checkbox" name="share" onChange={noop} onClick={() => { if (!myStories.isBlockList) { setSelectedContacts(myStories.members); } onClickOnlyShareWith(); }} > {({ checkboxNode, labelNode, checked }) => { return ( {i18n('icu:StoriesSettings__viewers', { count: myStories.members.length, })} ) } /> ); }} {!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; export function 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('SendStoryModal__new-custom--name-visibility')}
{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); }} onSelectConversation={shouldNeverBeCalled} renderMessageSearchResult={() => { shouldNeverBeCalled(); return
; }} rowCount={rowCount} setIsFetchingUUID={shouldNeverBeCalled} shouldRecomputeRowHeights={false} showChooseGroupMembers={shouldNeverBeCalled} showConversation={shouldNeverBeCalled} showUserNotFoundModal={shouldNeverBeCalled} theme={ThemeType.dark} />
)} ) : (
{i18n('noContactsFound')}
)} ); } type GroupStorySettingsModalProps = { i18n: LocalizerType; group: ConversationType; onClose(): void; onBackButtonClick(): void; getConversationByUuid(uuid: UUIDStringType): ConversationType | undefined; onRemoveGroup(group: ConversationType): void; }; export function GroupStorySettingsModal({ i18n, group, onClose, onBackButtonClick, getConversationByUuid, onRemoveGroup, }: GroupStorySettingsModalProps): JSX.Element { const groupMemberships = getGroupMemberships(group, getConversationByUuid); return (
{group.title}

{i18n('icu:GroupStorySettingsModal__members_title')}

{groupMemberships.memberships.map(membership => { const { member } = membership; return (

{member.title}

); })}

{i18n('icu:GroupStorySettingsModal__members_help', { groupTitle: group.title, })}


); }