signal-desktop/ts/components/StoriesSettingsModal.tsx
2022-08-31 10:18:18 -06:00

902 lines
28 KiB
TypeScript

// 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 { 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 { Modal } 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<ConversationType>;
distributionLists: Array<StoryDistributionListWithMembersDataType>;
getPreferredBadge: PreferredBadgeSelectorType;
hideStoriesSettings: () => unknown;
i18n: LocalizerType;
me: ConversationType;
onDeleteList: (listId: string) => unknown;
onDistributionListCreated: (
name: string,
viewerUuids: Array<UUIDStringType>
) => unknown;
onHideMyStoriesFrom: (viewerUuids: Array<UUIDStringType>) => unknown;
onRemoveMember: (listId: string, uuid: UUIDStringType | undefined) => unknown;
onRepliesNReactionsChanged: (
listId: string,
allowsReplies: boolean
) => unknown;
onViewersUpdated: (
listId: string,
viewerUuids: Array<UUIDStringType>
) => unknown;
setMyStoriesToAllSignalConnections: () => unknown;
toggleSignalConnectionsModal: () => unknown;
};
export enum Page {
DistributionLists = 'DistributionLists',
AddViewer = 'AddViewer',
ChooseViewers = 'ChooseViewers',
NameStory = 'NameStory',
HideStoryFrom = 'HideStoryFrom',
}
function filterConversations(
conversations: ReadonlyArray<ConversationType>,
searchTerm: string
) {
return filterAndSortConversationsByRecent(
conversations,
searchTerm,
undefined
).filter(conversation => conversation.uuid);
}
export const StoriesSettingsModal = ({
candidateConversations,
distributionLists,
getPreferredBadge,
hideStoriesSettings,
i18n,
me,
onDeleteList,
onDistributionListCreated,
onHideMyStoriesFrom,
onRemoveMember,
onRepliesNReactionsChanged,
onViewersUpdated,
setMyStoriesToAllSignalConnections,
toggleSignalConnectionsModal,
}: PropsType): JSX.Element => {
const [listToEditId, setListToEditId] = useState<string | undefined>(
undefined
);
const listToEdit = useMemo(
() => distributionLists.find(x => x.id === listToEditId),
[distributionLists, listToEditId]
);
const [page, setPage] = useState<Page>(Page.DistributionLists);
const [selectedContacts, setSelectedContacts] = useState<
Array<ConversationType>
>([]);
const resetChooseViewersScreen = useCallback(() => {
setSelectedContacts([]);
setPage(Page.DistributionLists);
}, []);
const [confirmDeleteListId, setConfirmDeleteListId] = useState<
string | undefined
>();
let content: JSX.Element | null;
if (page !== Page.DistributionLists) {
content = (
<EditDistributionList
candidateConversations={candidateConversations}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
onCreateList={(name, uuids) => {
onDistributionListCreated(name, uuids);
resetChooseViewersScreen();
}}
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();
}
}}
page={page}
selectedContacts={selectedContacts}
setSelectedContacts={setSelectedContacts}
/>
);
} else if (listToEdit) {
content = (
<DistributionListSettings
getPreferredBadge={getPreferredBadge}
i18n={i18n}
listToEdit={listToEdit}
onRemoveMember={onRemoveMember}
onRepliesNReactionsChanged={onRepliesNReactionsChanged}
setConfirmDeleteListId={setConfirmDeleteListId}
setMyStoriesToAllSignalConnections={setMyStoriesToAllSignalConnections}
setPage={setPage}
setSelectedContacts={setSelectedContacts}
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
/>
);
} else {
const privateStories = distributionLists.filter(
list => list.id !== MY_STORIES_ID
);
content = (
<>
<button
className="StoriesSettingsModal__list"
onClick={() => {
setListToEditId(MY_STORIES_ID);
}}
type="button"
>
<span className="StoriesSettingsModal__list__left">
<Avatar
acceptedMessageRequest={me.acceptedMessageRequest}
avatarPath={me.avatarPath}
badge={getPreferredBadge(me.badges)}
color={me.color}
conversationType={me.type}
i18n={i18n}
isMe
sharedGroupNames={me.sharedGroupNames}
size={AvatarSize.THIRTY_SIX}
theme={ThemeType.dark}
title={me.title}
/>
<span className="StoriesSettingsModal__list__title">
{i18n('Stories__mine')}
</span>
</span>
<span className="StoriesSettingsModal__list__viewers" />
</button>
<hr className="StoriesSettingsModal__divider" />
<button
className="StoriesSettingsModal__list"
onClick={() => {
setPage(Page.ChooseViewers);
}}
type="button"
>
<span className="StoriesSettingsModal__list__left">
<span className="StoriesSettingsModal__list__avatar--new" />
<span className="StoriesSettingsModal__list__title">
{i18n('StoriesSettings__new-list')}
</span>
</span>
</button>
{privateStories.map(list => (
<button
className="StoriesSettingsModal__list"
key={list.id}
onClick={() => {
setListToEditId(list.id);
}}
type="button"
>
<span className="StoriesSettingsModal__list__left">
<span className="StoriesSettingsModal__list__avatar--private" />
<span className="StoriesSettingsModal__list__title">
{list.name}
</span>
</span>
<span className="StoriesSettingsModal__list__viewers">
{list.members.length === 1
? i18n('StoriesSettings__viewers--singular', ['1'])
: i18n('StoriesSettings__viewers--plural', [
String(list.members.length),
])}
</span>
</button>
))}
</>
);
}
const isChoosingViewers =
page === Page.ChooseViewers || page === Page.AddViewer;
let modalTitle: string = i18n('StoriesSettings__title');
if (page === Page.HideStoryFrom) {
modalTitle = i18n('StoriesSettings__hide-story');
} else if (page === Page.NameStory) {
modalTitle = i18n('StoriesSettings__name-story');
} else if (isChoosingViewers) {
modalTitle = i18n('StoriesSettings__choose-viewers');
} else if (listToEdit) {
modalTitle = getStoryDistributionListName(
i18n,
listToEdit.id,
listToEdit.name
);
}
const hasBackButton = page !== Page.DistributionLists || listToEdit;
const hasStickyButtons =
isChoosingViewers || page === Page.NameStory || page === Page.HideStoryFrom;
return (
<>
<Modal
hasStickyButtons={hasStickyButtons}
hasXButton
i18n={i18n}
moduleClassName="StoriesSettingsModal__modal"
onBackButtonClick={
hasBackButton
? () => {
if (page === Page.HideStoryFrom) {
resetChooseViewersScreen();
} else if (page === Page.NameStory) {
setPage(Page.ChooseViewers);
} else if (isChoosingViewers) {
resetChooseViewersScreen();
} else if (listToEdit) {
setListToEditId(undefined);
}
}
: undefined
}
onClose={hideStoriesSettings}
theme={Theme.Dark}
title={modalTitle}
>
{content}
</Modal>
{confirmDeleteListId && (
<ConfirmationDialog
actions={[
{
action: () => {
onDeleteList(confirmDeleteListId);
setListToEditId(undefined);
},
style: 'negative',
text: i18n('delete'),
},
]}
i18n={i18n}
onClose={() => {
setConfirmDeleteListId(undefined);
}}
>
{i18n('StoriesSettings__delete-list--confirm')}
</ConfirmationDialog>
)}
</>
);
};
type DistributionListSettingsPropsType = {
i18n: LocalizerType;
listToEdit: StoryDistributionListWithMembersDataType;
setConfirmDeleteListId: (id: string) => unknown;
setPage: (page: Page) => unknown;
setSelectedContacts: (contacts: Array<ConversationType>) => unknown;
} & Pick<
PropsType,
| 'getPreferredBadge'
| 'onRemoveMember'
| 'onRepliesNReactionsChanged'
| 'setMyStoriesToAllSignalConnections'
| 'toggleSignalConnectionsModal'
>;
export const DistributionListSettings = ({
getPreferredBadge,
i18n,
listToEdit,
onRemoveMember,
onRepliesNReactionsChanged,
setConfirmDeleteListId,
setMyStoriesToAllSignalConnections,
setPage,
setSelectedContacts,
toggleSignalConnectionsModal,
}: DistributionListSettingsPropsType): JSX.Element => {
const [confirmRemoveMember, setConfirmRemoveMember] = useState<
| undefined
| {
listId: string;
title: string;
uuid: UUIDStringType | undefined;
}
>();
const isMyStories = listToEdit.id === MY_STORIES_ID;
return (
<>
{!isMyStories && (
<>
<div className="StoriesSettingsModal__list StoriesSettingsModal__list--no-pointer">
<span className="StoriesSettingsModal__list__left">
<span className="StoriesSettingsModal__list__avatar--private" />
<span className="StoriesSettingsModal__list__title">
<StoryDistributionListName
i18n={i18n}
id={listToEdit.id}
name={listToEdit.name}
/>
</span>
</span>
</div>
<hr className="StoriesSettingsModal__divider" />
</>
)}
<div className="StoriesSettingsModal__title">
{i18n('StoriesSettings__who-can-see')}
</div>
{isMyStories && (
<EditMyStoriesPrivacy
i18n={i18n}
learnMore="StoriesSettings__mine_disclaimer"
myStories={listToEdit}
onClickExclude={() => {
setPage(Page.HideStoryFrom);
}}
onClickOnlyShareWith={() => {
setPage(Page.AddViewer);
}}
setSelectedContacts={setSelectedContacts}
setMyStoriesToAllSignalConnections={
setMyStoriesToAllSignalConnections
}
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
/>
)}
{!isMyStories && (
<>
<button
className="StoriesSettingsModal__list"
onClick={() => {
setSelectedContacts(listToEdit.members);
setPage(Page.AddViewer);
}}
type="button"
>
<span className="StoriesSettingsModal__list__left">
<span className="StoriesSettingsModal__list__avatar--new" />
<span className="StoriesSettingsModal__list__title">
{i18n('StoriesSettings__add-viewer')}
</span>
</span>
</button>
{listToEdit.members.map(member => (
<div
className="StoriesSettingsModal__list StoriesSettingsModal__list--no-pointer"
key={member.id}
>
<span className="StoriesSettingsModal__list__left">
<Avatar
acceptedMessageRequest={member.acceptedMessageRequest}
avatarPath={member.avatarPath}
badge={getPreferredBadge(member.badges)}
color={member.color}
conversationType={member.type}
i18n={i18n}
isMe
sharedGroupNames={member.sharedGroupNames}
size={AvatarSize.THIRTY_SIX}
theme={ThemeType.dark}
title={member.title}
/>
<span className="StoriesSettingsModal__list__title">
{member.title}
</span>
</span>
<button
aria-label={i18n('StoriesSettings__remove--title', [
member.title,
])}
className="StoriesSettingsModal__list__delete"
onClick={() =>
setConfirmRemoveMember({
listId: listToEdit.id,
title: member.title,
uuid: member.uuid,
})
}
type="button"
/>
</div>
))}
</>
)}
<hr className="StoriesSettingsModal__divider" />
<div className="StoriesSettingsModal__title">
{i18n('StoriesSettings__replies-reactions--title')}
</div>
<Checkbox
checked={listToEdit.allowsReplies}
description={i18n('StoriesSettings__replies-reactions--description')}
label={i18n('StoriesSettings__replies-reactions--label')}
moduleClassName="StoriesSettingsModal__checkbox"
name="replies-reactions"
onChange={value => onRepliesNReactionsChanged(listToEdit.id, value)}
/>
{!isMyStories && (
<>
<hr className="StoriesSettingsModal__divider" />
<button
className="StoriesSettingsModal__delete-list"
onClick={() => setConfirmDeleteListId(listToEdit.id)}
type="button"
>
{i18n('StoriesSettings__delete-list')}
</button>
</>
)}
{confirmRemoveMember && (
<ConfirmationDialog
actions={[
{
action: () =>
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')}
</ConfirmationDialog>
)}
</>
);
};
type EditMyStoriesPrivacyPropsType = {
hasDisclaimerAbove?: boolean;
i18n: LocalizerType;
learnMore: string;
myStories: StoryDistributionListWithMembersDataType;
onClickExclude: () => unknown;
onClickOnlyShareWith: () => unknown;
setSelectedContacts: (contacts: Array<ConversationType>) => unknown;
} & Pick<
PropsType,
'setMyStoriesToAllSignalConnections' | 'toggleSignalConnectionsModal'
>;
export const EditMyStoriesPrivacy = ({
hasDisclaimerAbove,
i18n,
learnMore,
myStories,
onClickExclude,
onClickOnlyShareWith,
setSelectedContacts,
setMyStoriesToAllSignalConnections,
toggleSignalConnectionsModal,
}: EditMyStoriesPrivacyPropsType): JSX.Element => {
const disclaimerElement = (
<div className="StoriesSettingsModal__disclaimer">
<Intl
components={{
learnMore: (
<button
className="StoriesSettingsModal__disclaimer__learn-more"
onClick={toggleSignalConnectionsModal}
type="button"
>
{i18n('StoriesSettings__mine__disclaimer--learn-more')}
</button>
),
}}
i18n={i18n}
id={learnMore}
/>
</div>
);
return (
<>
{hasDisclaimerAbove && disclaimerElement}
<Checkbox
checked={!myStories.members.length}
description={i18n('StoriesSettings__mine__all--description')}
isRadio
label={i18n('StoriesSettings__mine__all--label')}
moduleClassName="StoriesSettingsModal__checkbox"
name="share"
onChange={() => {
setMyStoriesToAllSignalConnections();
}}
/>
<Checkbox
checked={myStories.isBlockList && myStories.members.length > 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();
}}
/>
<Checkbox
checked={!myStories.isBlockList && myStories.members.length > 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 EditDistributionListPropsType = {
onCreateList: (name: string, viewerUuids: Array<UUIDStringType>) => unknown;
onViewersUpdated: (viewerUuids: Array<UUIDStringType>) => unknown;
page: Page;
selectedContacts: Array<ConversationType>;
setSelectedContacts: (contacts: Array<ConversationType>) => unknown;
} & Pick<PropsType, 'candidateConversations' | 'getPreferredBadge' | 'i18n'>;
export const EditDistributionList = ({
candidateConversations,
getPreferredBadge,
i18n,
onCreateList,
onViewersUpdated,
page,
selectedContacts,
setSelectedContacts,
}: EditDistributionListPropsType): JSX.Element | null => {
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 isEditingDistributionList =
page === Page.AddViewer ||
page === Page.ChooseViewers ||
page === Page.NameStory ||
page === Page.HideStoryFrom;
useEffect(() => {
if (!isEditingDistributionList) {
setSearchTerm('');
}
}, [isEditingDistributionList]);
const contactLookup = useMemo(() => {
const map = new Map();
candidateConversations.forEach(contact => {
map.set(contact.id, contact);
});
return map;
}, [candidateConversations]);
const selectedConversationUuids: Set<UUIDStringType> = 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) {
return (
<>
<div className="StoriesSettingsModal__name-story-avatar-container">
<div className="StoriesSettingsModal__list__avatar--private StoriesSettingsModal__list__avatar--private--large" />
</div>
<Input
i18n={i18n}
onChange={setStoryName}
placeholder={i18n('StoriesSettings__name-placeholder')}
value={storyName}
/>
<div className="StoriesSettingsModal__title">
{i18n('StoriesSettings__who-can-see')}
</div>
{selectedContacts.map(contact => (
<div
className="StoriesSettingsModal__list StoriesSettingsModal__list--no-pointer"
key={contact.id}
>
<span className="StoriesSettingsModal__list__left">
<Avatar
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPath={contact.avatarPath}
badge={getPreferredBadge(contact.badges)}
color={contact.color}
conversationType={contact.type}
i18n={i18n}
isMe
sharedGroupNames={contact.sharedGroupNames}
size={AvatarSize.THIRTY_SIX}
theme={ThemeType.dark}
title={contact.title}
/>
<span className="StoriesSettingsModal__list__title">
{contact.title}
</span>
</span>
</div>
))}
<Modal.ButtonFooter>
<Button
disabled={!storyName}
onClick={() => {
onCreateList(storyName, Array.from(selectedConversationUuids));
setStoryName('');
}}
variant={ButtonVariant.Primary}
>
{i18n('done')}
</Button>
</Modal.ButtonFooter>
</>
);
}
if (
page === Page.AddViewer ||
page === Page.ChooseViewers ||
page === Page.HideStoryFrom
) {
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,
};
};
return (
<>
<SearchInput
disabled={candidateConversations.length === 0}
i18n={i18n}
placeholder={i18n('contactSearchPlaceholder')}
moduleClassName="StoriesSettingsModal__search"
onChange={event => {
setSearchTerm(event.target.value);
}}
value={searchTerm}
/>
{selectedContacts.length ? (
<ContactPills moduleClassName="StoriesSettingsModal__tags">
{selectedContacts.map(contact => (
<ContactPill
key={contact.id}
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPath={contact.avatarPath}
color={contact.color}
firstName={contact.firstName}
i18n={i18n}
id={contact.id}
isMe={contact.isMe}
name={contact.name}
phoneNumber={contact.phoneNumber}
profileName={contact.profileName}
sharedGroupNames={contact.sharedGroupNames}
title={contact.title}
onClickRemove={() => toggleSelectedConversation(contact.id)}
/>
))}
</ContactPills>
) : undefined}
{candidateConversations.length ? (
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => (
<div
className="StoriesSettingsModal__conversation-list"
ref={measureRef}
>
<ConversationList
dimensions={contentRect.bounds}
getPreferredBadge={getPreferredBadge}
getRow={getRow}
i18n={i18n}
onClickArchiveButton={shouldNeverBeCalled}
onClickContactCheckbox={(conversationId: string) => {
toggleSelectedConversation(conversationId);
}}
lookupConversationWithoutUuid={asyncShouldNeverBeCalled}
showConversation={shouldNeverBeCalled}
showUserNotFoundModal={shouldNeverBeCalled}
setIsFetchingUUID={shouldNeverBeCalled}
onSelectConversation={shouldNeverBeCalled}
renderMessageSearchResult={() => {
shouldNeverBeCalled();
return <div />;
}}
rowCount={rowCount}
shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled}
theme={ThemeType.dark}
/>
</div>
)}
</Measure>
) : (
<div className="module-ForwardMessageModal__no-candidate-contacts">
{i18n('noContactsFound')}
</div>
)}
{isChoosingViewers && (
<Modal.ButtonFooter>
<Button
disabled={selectedContacts.length === 0}
onClick={() => {
onViewersUpdated(Array.from(selectedConversationUuids));
}}
variant={ButtonVariant.Primary}
>
{page === Page.AddViewer ? i18n('done') : i18n('next2')}
</Button>
</Modal.ButtonFooter>
)}
{page === Page.HideStoryFrom && (
<Modal.ButtonFooter>
<Button
disabled={selectedContacts.length === 0}
onClick={() => {
onViewersUpdated(Array.from(selectedConversationUuids));
}}
variant={ButtonVariant.Primary}
>
{i18n('update')}
</Button>
</Modal.ButtonFooter>
)}
</>
);
}
return null;
};