// Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { noop } from 'lodash'; import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationWithStoriesType } from '../state/selectors/conversations'; import type { LocalizerType, ThemeType } from '../types/Util'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { Row } from './ConversationList'; import type { StoryDistributionListWithMembersDataType } from '../types/Stories'; import type { StoryDistributionIdString } from '../types/StoryDistributionId'; import type { ServiceIdString } from '../types/ServiceId'; 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 { 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'; import { UserText } from './UserText'; import { SizeObserver } from '../hooks/useSizeObserver'; 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, viewerServiceIds: Array ) => Promise; onHideMyStoriesFrom: (viewerServiceIds: Array) => unknown; onRemoveMembers: ( listId: string, serviceIds: Array ) => unknown; onRepliesNReactionsChanged: ( listId: string, allowsReplies: boolean ) => unknown; onViewersUpdated: ( listId: string, viewerServiceIds: Array ) => unknown; setMyStoriesToAllSignalConnections: () => unknown; storyViewReceiptsEnabled: boolean; theme: ThemeType; toggleSignalConnectionsModal: () => unknown; setStoriesDisabled: (value: boolean) => void; getConversationByServiceId: ( serviceId: ServiceIdString ) => 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.serviceId); } 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('icu:StoriesSettings__mine__all--label'); } if (!list.isBlockList) { return i18n('icu:SendStoryModal__only-share-with'); } return i18n('icu:StoriesSettings__mine__all--label'); } type DistributionListItemProps = { i18n: LocalizerType; distributionList: StoryDistributionListWithMembersDataType; me: ConversationType; signalConnections: Array; onSelectItemToEdit(id: StoryDistributionIdString): 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, theme, setStoriesDisabled, getConversationByServiceId, }: 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, serviceIds); 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={serviceIds => { if (listToEditId && page === Page.AddViewer) { onViewersUpdated(listToEditId, serviceIds); resetChooseViewersScreen(); } if (page === Page.ChooseViewers) { setPage(Page.NameStory); } if (page === Page.HideStoryFrom) { onHideMyStoriesFrom(serviceIds); resetChooseViewersScreen(); } }} selectedContacts={selectedContacts} setSelectedContacts={setSelectedContacts} theme={theme} /> ); } else if (listToEdit) { modal = handleClose => ( setListToEditId(undefined)} onClose={handleClose} /> ); } else if (groupToView) { modal = onClose => ( setGroupToViewId(null)} getConversationByServiceId={getConversationByServiceId} 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('icu:Stories__settings-toggle--description')}

); } return ( <> {!confirmDiscardModal && ( confirmDiscardIf(selectedContacts.length > 0, hideStoriesSettings) } > {modal} )} {confirmDeleteList && ( { onDeleteList(confirmDeleteList.id); setListToEditId(undefined); }, style: 'negative', text: i18n('icu:delete'), }, ]} i18n={i18n} onClose={() => { setConfirmDeleteList(undefined); }} > {i18n('icu:StoriesSettings__delete-list--confirm', { name: confirmDeleteList.name, })} )} {confirmRemoveGroup != null && ( { toggleGroupsForStorySend([confirmRemoveGroup.id]); setConfirmRemoveGroup(null); setGroupToViewId(null); }, style: 'negative', text: i18n('icu:delete'), }, ]} i18n={i18n} onClose={() => { setConfirmRemoveGroup(null); }} > {i18n('icu:StoriesSettings__remove_group--confirm', { groupTitle: confirmRemoveGroup.title, })} )} {confirmDiscardModal} ); } type DistributionListSettingsModalPropsType = { i18n: LocalizerType; listToEdit: StoryDistributionListWithMembersDataType; signalConnectionsCount: number; setConfirmDeleteList: (_: { id: StoryDistributionIdString; name: string; }) => unknown; setPage: (page: Page) => unknown; setSelectedContacts: (contacts: Array) => unknown; theme: ThemeType; 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, theme, toggleSignalConnectionsModal, signalConnectionsCount, }: DistributionListSettingsModalPropsType): JSX.Element { const [confirmRemoveMember, setConfirmRemoveMember] = useState< | undefined | { listId: string; title: string; serviceId: ServiceIdString; } >(); const isMyStory = listToEdit.id === MY_STORY_ID; const modalTitle = getStoryDistributionListName( i18n, listToEdit.id, listToEdit.name ); return ( {!isMyStory && ( <>

)}
{i18n('icu: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 => (
))} )}
{i18n('icu:StoriesSettings__replies-reactions--title')}
onRepliesNReactionsChanged(listToEdit.id, value)} /> {!isMyStory && ( <>
)} {confirmRemoveMember && ( onRemoveMembers(confirmRemoveMember.listId, [ confirmRemoveMember.serviceId, ]), style: 'negative', text: i18n('icu:StoriesSettings__remove--action'), }, ]} i18n={i18n} onClose={() => { setConfirmRemoveMember(undefined); }} title={i18n('icu:StoriesSettings__remove--title', { title: confirmRemoveMember.title, })} > {i18n('icu: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; kind: 'privacy' | 'mine'; myStories: StoryDistributionListWithMembersDataType; onClickExclude: () => unknown; onClickOnlyShareWith: () => unknown; setSelectedContacts: (contacts: Array) => unknown; signalConnectionsCount: number; } & Pick< PropsType, 'setMyStoriesToAllSignalConnections' | 'toggleSignalConnectionsModal' >; export function EditMyStoryPrivacy({ hasDisclaimerAbove, i18n, kind, myStories, onClickExclude, onClickOnlyShareWith, setSelectedContacts, setMyStoriesToAllSignalConnections, toggleSignalConnectionsModal, signalConnectionsCount, }: EditMyStoryPrivacyPropsType): JSX.Element { const learnMoreLink = (parts: Array) => ( ); const disclaimerElement = (
{kind === 'mine' ? ( ) : ( )}
); return ( <> {hasDisclaimerAbove && disclaimerElement} { setMyStoriesToAllSignalConnections(); }} > {({ checkboxNode, labelNode, checked }) => { return ( {i18n('icu:StoriesSettings__viewers', { count: signalConnectionsCount, })} ) } /> ); }} 0} isRadio label={i18n('icu: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('icu: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, viewerServiceIds: Array ) => unknown; onViewersUpdated: (viewerServiceIds: Array) => unknown; page: | Page.AddViewer | Page.ChooseViewers | Page.HideStoryFrom | Page.NameStory; selectedContacts: Array; onClose: () => unknown; setSelectedContacts: (contacts: Array) => unknown; theme: ThemeType; onBackButtonClick: () => void; } & Pick; export function EditDistributionListModal({ candidateConversations, getPreferredBadge, i18n, onCreateList, onViewersUpdated, page, onClose, selectedContacts, setSelectedContacts, theme, 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 selectConversationServiceIds: Set = useMemo( () => new Set( selectedContacts.map(contact => contact.serviceId).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('icu:SendStoryModal__new-custom--name-visibility')}
{i18n('icu:StoriesSettings__who-can-see')}
{selectedContacts.map(contact => (
))}
); } const rowCount = filteredConversations.length; const getRow = (index: number): undefined | Row => { const contact = filteredConversations[index]; if (!contact || !contact.serviceId) { return undefined; } const isSelected = selectConversationServiceIds.has(contact.serviceId); 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 ? ( {(ref, size) => (
{ toggleSelectedConversation(conversationId); }} onSelectConversation={shouldNeverBeCalled} blockConversation={shouldNeverBeCalled} removeConversation={shouldNeverBeCalled} onOutgoingAudioCallInConversation={shouldNeverBeCalled} onOutgoingVideoCallInConversation={shouldNeverBeCalled} renderMessageSearchResult={() => { shouldNeverBeCalled(); return
; }} rowCount={rowCount} setIsFetchingUUID={shouldNeverBeCalled} shouldRecomputeRowHeights={false} showChooseGroupMembers={shouldNeverBeCalled} showConversation={shouldNeverBeCalled} showUserNotFoundModal={shouldNeverBeCalled} theme={theme} />
)} ) : (
{i18n('icu:noContactsFound')}
)} ); } type GroupStorySettingsModalProps = { i18n: LocalizerType; group: ConversationType; onClose(): void; onBackButtonClick(): void; getConversationByServiceId( serviceId: ServiceIdString ): ConversationType | undefined; onRemoveGroup(group: ConversationType): void; }; export function GroupStorySettingsModal({ i18n, group, onClose, onBackButtonClick, getConversationByServiceId, onRemoveGroup, }: GroupStorySettingsModalProps): JSX.Element { const groupMemberships = getGroupMemberships( group, getConversationByServiceId ); return (

{i18n('icu:GroupStorySettingsModal__members_title')}

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

); })}

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


); }