Edit distribution lists via story settings menu

This commit is contained in:
Josh Perez 2022-07-20 20:07:09 -04:00 committed by GitHub
parent 9986d10947
commit e321e1fea8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 2403 additions and 102 deletions

View file

@ -15,6 +15,7 @@ export type PropsType = {
moduleClassName?: string;
name: string;
onChange: (value: boolean) => unknown;
onClick?: () => unknown;
};
export const Checkbox = ({
@ -26,6 +27,7 @@ export const Checkbox = ({
moduleClassName,
name,
onChange,
onClick,
}: PropsType): JSX.Element => {
const getClassName = getClassNamesFor('Checkbox', moduleClassName);
const id = useMemo(() => `${name}::${uuid()}`, [name]);
@ -39,12 +41,15 @@ export const Checkbox = ({
id={id}
name={name}
onChange={ev => onChange(ev.target.checked)}
onClick={onClick}
type={isRadio ? 'radio' : 'checkbox'}
/>
</div>
<div>
<label htmlFor={id}>{label}</label>
<div className={getClassName('__description')}>{description}</div>
<label htmlFor={id}>
<div>{label}</div>
<div className={getClassName('__description')}>{description}</div>
</label>
</div>
</div>
</div>

View file

@ -93,7 +93,9 @@ export function ContextMenuPopper<T>({
>
<div className={theme ? themeClassName(theme) : undefined}>
<div
className="ContextMenu__popper"
className={classNames('ContextMenu__popper', {
'ContextMenu__popper--single-item': menuOptions.length === 1,
})}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}

View file

@ -34,9 +34,12 @@ import type { BodyRangeType, LocalizerType, ThemeType } from '../types/Util';
import { ModalHost } from './ModalHost';
import { SearchInput } from './SearchInput';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import { assert } from '../util/assert';
import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations';
import { useAnimated } from '../hooks/useAnimated';
import {
shouldNeverBeCalled,
asyncShouldNeverBeCalled,
} from '../util/shouldNeverBeCalled';
export type DataPropsType = {
attachments?: Array<AttachmentType>;
@ -470,15 +473,3 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
</>
);
};
function shouldNeverBeCalled(..._args: ReadonlyArray<unknown>): void {
assert(false, 'This should never be called. Doing nothing');
}
async function asyncShouldNeverBeCalled(
..._args: ReadonlyArray<unknown>
): Promise<undefined> {
shouldNeverBeCalled();
return undefined;
}

View file

@ -12,6 +12,7 @@ import { missingCaseError } from '../util/missingCaseError';
import { ButtonVariant } from './Button';
import { ConfirmationDialog } from './ConfirmationDialog';
import { SignalConnectionsModal } from './SignalConnectionsModal';
import { WhatsNewModal } from './WhatsNewModal';
type PropsType = {
@ -28,6 +29,12 @@ type PropsType = {
// SafetyNumberModal
safetyNumberModalContactId?: string;
renderSafetyNumber: () => JSX.Element;
// SignalConnectionsModal
isSignalConnectionsVisible: boolean;
toggleSignalConnectionsModal: () => unknown;
// StoriesSettings
isStoriesSettingsVisible: boolean;
renderStoriesSettings: () => JSX.Element;
// UserNotFoundModal
hideUserNotFoundModal: () => unknown;
userNotFoundModalState?: UserNotFoundModalStateType;
@ -50,6 +57,12 @@ export const GlobalModalContainer = ({
// SafetyNumberModal
safetyNumberModalContactId,
renderSafetyNumber,
// SignalConnectionsModal
isSignalConnectionsVisible,
toggleSignalConnectionsModal,
// StoriesSettings
isStoriesSettingsVisible,
renderStoriesSettings,
// UserNotFoundModal
hideUserNotFoundModal,
userNotFoundModalState,
@ -105,5 +118,18 @@ export const GlobalModalContainer = ({
return renderForwardMessageModal();
}
if (isSignalConnectionsVisible) {
return (
<SignalConnectionsModal
i18n={i18n}
onClose={toggleSignalConnectionsModal}
/>
);
}
if (isStoriesSettingsVisible) {
return renderStoriesSettings();
}
return null;
};

View file

@ -23,7 +23,9 @@ const LOREM_IPSUM =
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris. Maecenas congue ligula ac quam viverra nec consectetur ante hendrerit. Donec et mollis dolor. Praesent et diam eget libero egestas mattis sit amet vitae augue. Nam tincidunt congue enim, ut porta lorem lacinia consectetur. Donec ut libero sed arcu vehicula ultricies a non tortor. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ut gravida lorem. Ut turpis felis, pulvinar a semper sed, adipiscing id dolor. Pellentesque auctor nisi id magna consequat sagittis. Curabitur dapibus enim sit amet elit pharetra tincidunt feugiat nisl imperdiet. Ut convallis libero in urna ultrices accumsan. Donec sed odio eros. Donec viverra mi quis quam pulvinar at malesuada arcu rhoncus. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In rutrum accumsan ultricies. Mauris vitae nisi at sem facilisis semper ac in est.';
export const BareBonesShort = (): JSX.Element => (
<Modal i18n={i18n}>Hello world!</Modal>
<Modal i18n={i18n} useFocusTrap={false}>
Hello world!
</Modal>
);
BareBonesShort.story = {
@ -31,7 +33,7 @@ BareBonesShort.story = {
};
export const BareBonesLong = (): JSX.Element => (
<Modal i18n={i18n}>
<Modal i18n={i18n} useFocusTrap={false}>
<p>{LOREM_IPSUM}</p>
<p>{LOREM_IPSUM}</p>
<p>{LOREM_IPSUM}</p>
@ -96,7 +98,7 @@ LotsOfButtonsInTheFooter.story = {
};
export const LongBodyWithTitle = (): JSX.Element => (
<Modal i18n={i18n} title="Hello world" onClose={onClose}>
<Modal i18n={i18n} title="Hello world" onClose={onClose} useFocusTrap={false}>
<p>{LOREM_IPSUM}</p>
<p>{LOREM_IPSUM}</p>
<p>{LOREM_IPSUM}</p>
@ -195,3 +197,19 @@ export const StickyFooterLotsOfButtons = (): JSX.Element => (
StickyFooterLotsOfButtons.story = {
name: 'Sticky footer, Lots of buttons',
};
export const WithBackButton = (): JSX.Element => (
<Modal
hasXButton
i18n={i18n}
onBackButtonClick={noop}
useFocusTrap={false}
title="The Modal Title"
>
Hello world!
</Modal>
);
WithBackButton.story = {
name: 'Back Button',
};

View file

@ -23,6 +23,7 @@ type PropsType = {
hasXButton?: boolean;
i18n: LocalizerType;
moduleClassName?: string;
onBackButtonClick?: () => unknown;
onClose?: () => void;
title?: ReactNode;
useFocusTrap?: boolean;
@ -42,6 +43,7 @@ export function Modal({
i18n,
moduleClassName,
noMouseClose,
onBackButtonClick,
onClose = noop,
title,
theme,
@ -70,6 +72,7 @@ export function Modal({
hasXButton={hasXButton}
i18n={i18n}
moduleClassName={moduleClassName}
onBackButtonClick={onBackButtonClick}
onClose={close}
title={title}
>
@ -86,6 +89,7 @@ export function ModalWindow({
hasXButton,
i18n,
moduleClassName,
onBackButtonClick,
onClose = noop,
title,
}: Readonly<PropsType>): JSX.Element {
@ -97,7 +101,7 @@ export function ModalWindow({
const [scrolled, setScrolled] = useState(false);
const [hasOverflow, setHasOverflow] = useState(false);
const hasHeader = Boolean(hasXButton || title);
const hasHeader = Boolean(hasXButton || title || onBackButtonClick);
const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName);
function handleResize({ scroll }: ContentRect) {
@ -127,14 +131,21 @@ export function ModalWindow({
}}
>
{hasHeader && (
<div className={getClassName('__header')}>
{hasXButton && (
<div
className={classNames(
getClassName('__header'),
onBackButtonClick
? getClassName('__header--with-back-button')
: null
)}
>
{onBackButtonClick && (
<button
aria-label={i18n('close')}
type="button"
className={getClassName('__close-button')}
aria-label={i18n('back')}
className={getClassName('__back-button')}
onClick={onBackButtonClick}
tabIndex={0}
onClick={onClose}
type="button"
/>
)}
{title && (
@ -147,6 +158,18 @@ export function ModalWindow({
{title}
</h1>
)}
{hasXButton && !title && (
<div className={getClassName('__title')} />
)}
{hasXButton && (
<button
aria-label={i18n('close')}
className={getClassName('__close-button')}
onClick={onClose}
tabIndex={0}
type="button"
/>
)}
</div>
)}
<Measure scroll onResize={handleResize}>

View file

@ -7,8 +7,9 @@ import type { LocalizerType } from '../types/Util';
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ContextMenu } from './ContextMenu';
import { MY_STORIES_ID, StoryViewModeType } from '../types/Stories';
import { StoryViewModeType } from '../types/Stories';
import { MessageTimestamp } from './conversation/MessageTimestamp';
import { StoryDistributionListName } from './StoryDistributionListName';
import { StoryImage } from './StoryImage';
import { Theme } from '../util/theme';
@ -69,9 +70,11 @@ export const MyStories = ({
{myStories.map(list => (
<div className="MyStories__distribution" key={list.distributionId}>
<div className="MyStories__distribution__title">
{list.distributionId === MY_STORIES_ID
? i18n('Stories__mine')
: list.distributionName}
<StoryDistributionListName
i18n={i18n}
id={list.distributionId}
name={list.distributionName}
/>
</div>
{list.stories.map(story => (
<div className="MyStories__story" key={story.timestamp}>

View file

@ -0,0 +1,28 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import React from 'react';
import type { PropsType } from './SignalConnectionsModal';
import enMessages from '../../_locales/en/messages.json';
import { SignalConnectionsModal } from './SignalConnectionsModal';
import { setupI18n } from '../util/setupI18n';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/SignalConnectionsModal',
component: SignalConnectionsModal,
argTypes: {
i18n: {
defaultValue: i18n,
},
onClose: { action: true },
},
} as Meta;
const Template: Story<PropsType> = args => <SignalConnectionsModal {...args} />;
export const Modal = Template.bind({});
Modal.args = {};

View file

@ -0,0 +1,55 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Button, ButtonVariant } from './Button';
import { Intl } from './Intl';
import { Modal } from './Modal';
export type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export const SignalConnectionsModal = ({
i18n,
onClose,
}: PropsType): JSX.Element => {
return (
<Modal hasXButton i18n={i18n} onClose={onClose}>
<div className="SignalConnectionsModal">
<i className="SignalConnectionsModal__icon" />
<div className="SignalConnectionsModal__description">
<Intl
components={{
connections: (
<strong>{i18n('SignalConnectionsModal__title')}</strong>
),
}}
i18n={i18n}
id="SignalConnectionsModal__header"
/>
</div>
<ul className="SignalConnectionsModal__list">
<li>{i18n('SignalConnectionsModal__bullet--1')}</li>
<li>{i18n('SignalConnectionsModal__bullet--2')}</li>
<li>{i18n('SignalConnectionsModal__bullet--3')}</li>
</ul>
<div className="SignalConnectionsModal__description">
{i18n('SignalConnectionsModal__footer')}
</div>
<div className="SignalConnectionsModal__button">
<Button onClick={onClose} variant={ButtonVariant.Primary}>
{i18n('Confirmation--confirm')}
</Button>
</div>
</div>
</Modal>
);
};

View file

@ -46,6 +46,7 @@ export default {
renderStoryCreator: { action: true },
renderStoryViewer: { action: true },
showConversation: { action: true },
showStoriesSettings: { action: true },
stories: {
defaultValue: [],
},

View file

@ -33,6 +33,7 @@ export type PropsType = {
queueStoryDownload: (storyId: string) => unknown;
renderStoryCreator: (props: SmartStoryCreatorPropsType) => JSX.Element;
showConversation: ShowConversationType;
showStoriesSettings: () => unknown;
stories: Array<ConversationStoryType>;
toggleHideStories: (conversationId: string) => unknown;
toggleStoriesView: () => unknown;
@ -52,6 +53,7 @@ export const Stories = ({
queueStoryDownload,
renderStoryCreator,
showConversation,
showStoriesSettings,
stories,
toggleHideStories,
toggleStoriesView,
@ -98,6 +100,7 @@ export const Stories = ({
setIsShowingStoryCreator(true);
}
}}
onStoriesSettings={showStoriesSettings}
onStoryClicked={viewUserStories}
queueStoryDownload={queueStoryDownload}
showConversation={showConversation}

View file

@ -15,9 +15,11 @@ import type {
StoryViewType,
} from '../types/Stories';
import type { LocalizerType } from '../types/Util';
import { ContextMenu } from './ContextMenu';
import { MyStoriesButton } from './MyStoriesButton';
import { SearchInput } from './SearchInput';
import { StoryListItem } from './StoryListItem';
import { Theme } from '../util/theme';
import { isNotNil } from '../util/isNotNil';
const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationStoryType> = {
@ -63,6 +65,7 @@ export type PropsType = {
myStories: Array<MyStoryType>;
onAddStory: () => unknown;
onMyStoriesClicked: () => unknown;
onStoriesSettings: () => unknown;
onStoryClicked: (conversationId: string) => unknown;
queueStoryDownload: (storyId: string) => unknown;
showConversation: ShowConversationType;
@ -78,6 +81,7 @@ export const StoriesPane = ({
myStories,
onAddStory,
onMyStoriesClicked,
onStoriesSettings,
onStoryClicked,
queueStoryDownload,
showConversation,
@ -117,6 +121,21 @@ export const StoriesPane = ({
onClick={onAddStory}
type="button"
/>
<ContextMenu
buttonClassName="Stories__pane__settings"
i18n={i18n}
menuOptions={[
{
onClick: () => onStoriesSettings(),
label: i18n('StoriesSettings__context-menu'),
},
]}
popperOptions={{
placement: 'bottom',
strategy: 'absolute',
}}
theme={Theme.Dark}
/>
</div>
<SearchInput
i18n={i18n}

View file

@ -0,0 +1,105 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import React from 'react';
import type { PropsType } from './StoriesSettingsModal';
import enMessages from '../../_locales/en/messages.json';
import { MY_STORIES_ID } from '../types/Stories';
import { StoriesSettingsModal } from './StoriesSettingsModal';
import { UUID } from '../types/UUID';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/StoriesSettingsModal',
component: StoriesSettingsModal,
argTypes: {
candidateConversations: {
defaultValue: Array.from(Array(100), () => getDefaultConversation()),
},
distributionLists: {
defaultValue: [],
},
getPreferredBadge: { action: true },
hideStoriesSettings: { action: true },
i18n: {
defaultValue: i18n,
},
me: {
defaultValue: getDefaultConversation(),
},
onDeleteList: { action: true },
onDistributionListCreated: { action: true },
onHideMyStoriesFrom: { action: true },
onRemoveMember: { action: true },
onRepliesNReactionsChanged: { action: true },
onViewersUpdated: { action: true },
setMyStoriesToAllSignalConnections: { action: true },
toggleSignalConnectionsModal: { action: true },
},
} as Meta;
const Template: Story<PropsType> = args => <StoriesSettingsModal {...args} />;
export const MyStories = Template.bind({});
MyStories.args = {
distributionLists: [
{
allowsReplies: true,
id: MY_STORIES_ID,
isBlockList: false,
members: [],
name: MY_STORIES_ID,
},
],
};
export const MyStoriesBlockList = Template.bind({});
MyStoriesBlockList.args = {
distributionLists: [
{
allowsReplies: true,
id: MY_STORIES_ID,
isBlockList: true,
members: Array.from(Array(2), () => getDefaultConversation()),
name: MY_STORIES_ID,
},
],
};
export const MyStoriesExclusive = Template.bind({});
MyStoriesExclusive.args = {
distributionLists: [
{
allowsReplies: false,
id: MY_STORIES_ID,
isBlockList: false,
members: Array.from(Array(11), () => getDefaultConversation()),
name: MY_STORIES_ID,
},
],
};
export const SingleList = Template.bind({});
SingleList.args = {
distributionLists: [
{
allowsReplies: true,
id: MY_STORIES_ID,
isBlockList: false,
members: [],
name: MY_STORIES_ID,
},
{
allowsReplies: true,
id: UUID.generate().toString(),
isBlockList: false,
members: Array.from(Array(4), () => getDefaultConversation()),
name: 'Thailand 2021',
},
],
};

View file

@ -0,0 +1,766 @@
// 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 { 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;
};
enum Page {
DistributionLists = 'DistributionLists',
AddViewer = 'AddViewer',
ChooseViewers = 'ChooseViewers',
NameStory = 'NameStory',
HideStoryFrom = 'HideStoryFrom',
}
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 [storyName, setStoryName] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [filteredConversations, setFilteredConversations] = useState(
filterAndSortConversationsByRecent(
candidateConversations,
searchTerm,
undefined
)
);
const [selectedContacts, setSelectedContacts] = useState<
Array<ConversationType>
>([]);
const contactLookup = useMemo(() => {
const map = new Map();
candidateConversations.forEach(contact => {
map.set(contact.id, contact);
});
return map;
}, [candidateConversations]);
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 normalizedSearchTerm = searchTerm.trim();
useEffect(() => {
const timeout = setTimeout(() => {
setFilteredConversations(
filterAndSortConversationsByRecent(
candidateConversations,
normalizedSearchTerm,
undefined
)
);
}, 200);
return () => {
clearTimeout(timeout);
};
}, [candidateConversations, normalizedSearchTerm, setFilteredConversations]);
const resetChooseViewersScreen = useCallback(() => {
setSelectedContacts([]);
setSearchTerm('');
setPage(Page.DistributionLists);
}, []);
const selectedConversationUuids: Set<UUIDStringType> = useMemo(
() =>
new Set(selectedContacts.map(contact => contact.uuid).filter(isNotNil)),
[selectedContacts]
);
const [confirmDeleteListId, setConfirmDeleteListId] = useState<
string | undefined
>();
const [confirmRemoveMember, setConfirmRemoveMember] = useState<
| undefined
| {
listId: string;
title: string;
uuid: UUIDStringType | undefined;
}
>();
let content: JSX.Element;
if (page === Page.NameStory) {
content = (
<>
<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>
))}
</>
);
} else 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.fromString(contact.uuid)
);
return {
type: RowType.ContactCheckbox,
contact,
isChecked: isSelected,
};
};
content = (
<>
<SearchInput
disabled={candidateConversations.length === 0}
i18n={i18n}
placeholder={i18n('contactSearchPlaceholder')}
moduleClassName="StoriesSettingsModal__search"
onChange={event => {
setSearchTerm(event.target.value);
}}
value={searchTerm}
/>
{selectedContacts.length ? (
<div className="StoriesSettingsModal__tags">
{selectedContacts.map(contact => (
<div className="StoriesSettingsModal__tag" key={contact.id}>
<Avatar
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPath={contact.avatarPath}
badge={getPreferredBadge(contact.badges)}
color={contact.color}
conversationType={contact.type}
i18n={i18n}
isMe={contact.isMe}
sharedGroupNames={contact.sharedGroupNames}
size={AvatarSize.TWENTY_EIGHT}
theme={ThemeType.dark}
title={contact.title}
/>
<span className="StoriesSettingsModal__tag__name">
{contact.firstName ||
contact.profileName ||
contact.phoneNumber}
</span>
<button
aria-label={i18n('StoriesSettings__remove--title', [
contact.title,
])}
className="StoriesSettingsModal__tag__remove"
onClick={() => toggleSelectedConversation(contact.id)}
type="button"
/>
</div>
))}
</div>
) : 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>
)}
</>
);
} else if (listToEdit) {
const isMyStories = listToEdit.id === MY_STORIES_ID;
content = (
<>
{!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 && (
<>
<Checkbox
checked={!listToEdit.members.length}
description={i18n('StoriesSettings__mine__all--description')}
isRadio
label={i18n('StoriesSettings__mine__all--label')}
moduleClassName="StoriesSettingsModal__checkbox"
name="share"
onChange={() => {
setMyStoriesToAllSignalConnections();
}}
/>
<Checkbox
checked={listToEdit.isBlockList && listToEdit.members.length > 0}
description={i18n('StoriesSettings__mine__exclude--description', [
listToEdit.isBlockList
? String(listToEdit.members.length)
: '0',
])}
isRadio
label={i18n('StoriesSettings__mine__exclude--label')}
moduleClassName="StoriesSettingsModal__checkbox"
name="share"
onChange={noop}
onClick={() => {
if (listToEdit.isBlockList) {
setSelectedContacts(listToEdit.members);
}
setPage(Page.HideStoryFrom);
}}
/>
<Checkbox
checked={!listToEdit.isBlockList && listToEdit.members.length > 0}
description={
!listToEdit.isBlockList && listToEdit.members.length
? i18n('StoriesSettings__mine__only--description--people', [
String(listToEdit.members.length),
])
: i18n('StoriesSettings__mine__only--description')
}
isRadio
label={i18n('StoriesSettings__mine__only--label')}
moduleClassName="StoriesSettingsModal__checkbox"
name="share"
onChange={noop}
onClick={() => {
if (!listToEdit.isBlockList) {
setSelectedContacts(listToEdit.members);
}
setPage(Page.AddViewer);
}}
/>
<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="StoriesSettings__mine__disclaimer"
/>
</div>
</>
)}
{!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>
</>
)}
</>
);
} 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('StoriesSettingsModal__list__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}
{isChoosingViewers && (
<Modal.ButtonFooter>
<Button
disabled={selectedContacts.length === 0}
onClick={() => {
if (listToEdit && page === Page.AddViewer) {
onViewersUpdated(
listToEdit.id,
Array.from(selectedConversationUuids)
);
resetChooseViewersScreen();
}
if (page === Page.ChooseViewers) {
setPage(Page.NameStory);
}
}}
variant={ButtonVariant.Primary}
>
{page === Page.AddViewer ? i18n('done') : i18n('next2')}
</Button>
</Modal.ButtonFooter>
)}
{page === Page.NameStory && (
<Modal.ButtonFooter>
<Button
disabled={!storyName}
onClick={() => {
onDistributionListCreated(
storyName,
Array.from(selectedConversationUuids)
);
setStoryName('');
resetChooseViewersScreen();
}}
variant={ButtonVariant.Primary}
>
{i18n('done')}
</Button>
</Modal.ButtonFooter>
)}
{page === Page.HideStoryFrom && (
<Modal.ButtonFooter>
<Button
disabled={selectedContacts.length === 0}
onClick={() => {
onHideMyStoriesFrom(Array.from(selectedConversationUuids));
resetChooseViewersScreen();
}}
variant={ButtonVariant.Primary}
>
{i18n('update')}
</Button>
</Modal.ButtonFooter>
)}
</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>
)}
{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>
)}
</>
);
};

View file

@ -0,0 +1,21 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { getStoryDistributionListName } from '../types/Stories';
type PropsType = {
i18n: LocalizerType;
id: string;
name: string;
};
export const StoryDistributionListName = ({
i18n,
id,
name,
}: PropsType): JSX.Element => {
return <>{getStoryDistributionListName(i18n, id, name)}</>;
};

View file

@ -15,7 +15,6 @@ import Measure from 'react-measure';
import type { LocalizerType, ThemeType } from '../../../../types/Util';
import { getUsernameFromSearch } from '../../../../types/Username';
import { assert } from '../../../../util/assert';
import { refMerger } from '../../../../util/refMerger';
import { useRestoreFocus } from '../../../../hooks/useRestoreFocus';
import { missingCaseError } from '../../../../util/missingCaseError';
@ -40,6 +39,7 @@ import { ConversationList, RowType } from '../../../ConversationList';
import { ContactCheckboxDisabledReason } from '../../../conversationList/ContactCheckbox';
import { Button, ButtonVariant } from '../../../Button';
import { SearchInput } from '../../../SearchInput';
import { shouldNeverBeCalled } from '../../../../util/shouldNeverBeCalled';
export type StatePropsType = {
regionCode: string | undefined;
@ -399,7 +399,3 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
</ModalHost>
);
};
function shouldNeverBeCalled(..._args: ReadonlyArray<unknown>): unknown {
assert(false, 'This should never be called. Doing nothing');
}