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

@ -1123,6 +1123,12 @@
"message": "Done",
"description": "Label for done"
},
"update": {
"message": "Update"
},
"next2": {
"message": "Next"
},
"on": {
"message": "On",
"description": "Label for when something is turned on"
@ -7123,6 +7129,36 @@
"message": "Delete this story? It will also be deleted for everyone who received it.",
"description": "Confirmation dialog description text for deleting a story"
},
"SignalConnectionsModal__title": {
"message": "Signal Connections",
"description": "The phrase/term: 'Signal Connections'"
},
"SignalConnectionsModal__header": {
"message": "$connections$ are people you've chosen to trust, either by:",
"description": "The beginning sentence to list the different ways a signal connection is formed",
"placeholders": {
"connections": {
"content": "$1",
"example": "Signal Connections"
}
}
},
"SignalConnectionsModal__bullet--1": {
"message": "Starting a conversation",
"description": "A way that signal connection is formed"
},
"SignalConnectionsModal__bullet--2": {
"message": "Accepting a message request",
"description": "A way that signal connection is formed"
},
"SignalConnectionsModal__bullet--3": {
"message": "Having them in your system contacts",
"description": "A way that signal connection is formed"
},
"SignalConnectionsModal__footer": {
"message": "Your connections can see your name and photo, and can see posts to \"My Story\" unless you hide it from them",
"description": "Additional information about signal connections and the stories they can see"
},
"Stories__title": {
"message": "Stories",
"description": "Title for the stories list"
@ -7169,6 +7205,154 @@
"message": "Sending reaction...",
"description": "Toast message"
},
"StoriesSettings__title": {
"message": "Story settings",
"description": "Title for the story settings modal"
},
"StoriesSettings__new-list": {
"message": "New private story",
"description": "Label to create a new private story list"
},
"StoriesSettings__viewers--singular": {
"message": "$num$ viewer",
"description": "A single viewer",
"placeholders": {
"num": {
"content": "$1",
"example": "1"
}
}
},
"StoriesSettings__viewers--plural": {
"message": "$num$ viewers",
"description": "More than one viewer",
"placeholders": {
"num": {
"content": "$1",
"example": "14"
}
}
},
"StoriesSettings__who-can-see": {
"message": "Who can see this story",
"description": "Title for the who can see this story section"
},
"StoriesSettings__add-viewer": {
"message": "Add viewer",
"description": "Button label to add a viewer to a story"
},
"StoriesSettings__remove--action": {
"message": "Remove",
"description": "Button to remove a member from a private list"
},
"StoriesSettings__remove--title": {
"message": "Remove $title$",
"description": "Title of the confirmation dialog, has a person's name",
"placeholders": {
"title": {
"content": "$1",
"example": "Aahron Lee"
}
}
},
"StoriesSettings__remove--body": {
"message": "This person will no longer see your story.",
"description": "Body of the confirmation dialog to remove someone from a private distribution list"
},
"StoriesSettings__replies-reactions--title": {
"message": "Replies & reactions",
"description": "Title for the replies & reactions section"
},
"StoriesSettings__replies-reactions--label": {
"message": "Allow replies & reactions",
"description": "Checkbox label to allow or disallow replies to your stories"
},
"StoriesSettings__replies-reactions--description": {
"message": "Let people who can view your story react and reply.",
"description": "Description of checkbox to allow or disallow replies to your stories"
},
"StoriesSettings__delete-list": {
"message": "Delete private story",
"description": "Button label to delete a private distribution list"
},
"StoriesSettings__delete-list--confirm": {
"message": "Delete private story?",
"description": "Confirmation text to delete a private distribution list"
},
"StoriesSettings__choose-viewers": {
"message": "Choose Viewers",
"description": "Modal title when choosing to add a viewer to a private distribution list"
},
"StoriesSettings__name-story": {
"message": "Name this story",
"description": "Modal title when naming a private distribution list"
},
"StoriesSettings__name-placeholder": {
"message": "Story name (required)",
"description": "Placeholder for input field"
},
"StoriesSettings__hide-story": {
"message": "Hide story from",
"description": "Modal title when hiding people from my stories"
},
"StoriesSettings__mine__all--label": {
"message": "All Signal connections",
"description": "Input label to describe all signal connections"
},
"StoriesSettings__mine__all--description": {
"message": "Share with all connections",
"description": "Description of button StoriesSettings__mine__all--label"
},
"StoriesSettings__mine__exclude--label": {
"message": "All Signal connections except...",
"description": "Input label to create a block list"
},
"StoriesSettings__mine__exclude--description": {
"message": "$num$ people excluded",
"description": "Description of how many people are excluded in a list",
"placeholders": {
"num": {
"content": "$1",
"example": "0"
}
}
},
"StoriesSettings__mine__only--label": {
"message": "Only share with...",
"description": "Input label to create an exclusive allow list"
},
"StoriesSettings__mine__only--description": {
"message": "Only share with selected people",
"description": "Description of button StoriesSettings__mine__only--label"
},
"StoriesSettings__mine__only--description--people": {
"message": "$num$ people",
"description": "Description of how many people are in the exclusive allow list",
"placeholders": {
"num": {
"content": "$1",
"example": "7"
}
}
},
"StoriesSettings__mine__disclaimer": {
"message": "Choose who can view your story. Changes won't affect stories you've already sent. $learnMore$",
"description": "Disclaimer on how changes to story settings work",
"placeholders": {
"learnMore": {
"content": "$1",
"example": "<a>Learn more</a>"
}
}
},
"StoriesSettings__mine__disclaimer--learn-more": {
"message": "Learn more.",
"description": "Learn more link to learn about who can view your story"
},
"StoriesSettings__context-menu": {
"message": "Story settings",
"description": "Button label to get to story settings"
},
"Stories__settings-toggle--title": {
"message": "Share & View Stories",
"description": "Select box title for the stories on/off toggle"

View file

@ -190,7 +190,7 @@
"@babel/preset-typescript": "7.17.12",
"@electron/fuses": "1.5.0",
"@mixer/parallel-prettier": "2.0.1",
"@signalapp/mock-server": "2.0.1",
"@signalapp/mock-server": "2.1.0",
"@storybook/addon-a11y": "6.5.6",
"@storybook/addon-actions": "6.5.6",
"@storybook/addon-controls": "6.5.6",

View file

@ -7,6 +7,10 @@
margin: 0;
padding: 6px 0;
width: auto;
&--single-item {
padding: 0;
}
}
&__title {
@ -121,9 +125,15 @@
&--focused,
&:focus,
&:active {
border-radius: 6px;
box-shadow: 0 0 1px 1px $color-ultramarine;
outline: none;
@include keyboard-mode {
border-radius: 6px;
box-shadow: 0 0 1px 1px $color-ultramarine;
outline: none;
}
}
}
&__popper--single-item &__option {
padding: 12px 6px;
}
}

View file

@ -12,7 +12,6 @@
max-height: 89vh;
display: flex;
flex-direction: column;
@include light-theme() {
background: $color-white;
color: $color-gray-90;
@ -24,21 +23,76 @@
}
&__header {
position: sticky;
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 1em;
padding: 16px 16px 0 16px;
position: sticky;
&--with-back-button .module-Modal__title {
text-align: center;
}
}
&__title {
@include font-body-1-bold;
margin: 0 0 1em 0;
margin: 0;
padding: 0;
flex: 1;
}
&__back-button {
@include button-reset;
border-radius: 4px;
height: 24px;
width: 24px;
&::before {
content: '';
display: block;
width: 100%;
height: 100%;
@include light-theme {
@include color-svg(
'../images/icons/v2/chevron-left-24.svg',
$color-gray-75
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/chevron-left-24.svg',
$color-gray-15
);
}
}
@include light-theme {
&:hover,
&:focus {
background: $color-gray-02;
}
&:active {
background: $color-gray-05;
}
}
@include dark-theme {
&:hover,
&:focus {
background: $color-gray-80;
}
&:active {
background: $color-gray-75;
}
}
}
&__close-button {
@include button-reset;
border-radius: 4px;
float: right;
height: 24px;
width: 24px;

View file

@ -0,0 +1,24 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.SignalConnectionsModal {
color: $color-gray-05;
&__list {
margin: 16px 0;
li {
margin: 8px 0;
}
}
&__button {
display: flex;
justify-content: center;
margin-top: 24px;
button {
min-width: 150px;
}
}
}

View file

@ -21,6 +21,20 @@
width: 380px;
padding-top: calc(14px + var(--title-bar-drag-area-height));
&__settings {
margin-left: 24px;
opacity: 1;
&::after {
@include dark-theme {
@include color-svg(
'../images/icons/v2/more-horiz-24.svg',
$color-white
);
}
}
}
&__header {
align-items: center;
display: flex;

View file

@ -0,0 +1,177 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.StoriesSettingsModal {
&__modal {
.module-conversation-list {
padding: 0;
}
.module-conversation-list__item--contact-or-conversation {
padding: 0;
}
}
&__list {
@include button-reset;
@include font-body-1;
align-items: center;
display: flex;
height: 52px;
justify-content: space-between;
width: 100%;
&--no-pointer {
cursor: inherit;
}
&__viewers {
color: $color-gray-25;
}
&__left {
display: flex;
align-items: center;
}
&__avatar {
@mixin avatar($svg) {
@include rounded-corners;
display: inline-flex;
justify-content: center;
align-items: center;
width: 36px;
height: 36px;
background: $color-gray-75;
&::after {
@include color-svg($svg, $color-white);
content: '';
height: 20px;
width: 20px;
}
}
&--new {
@include avatar('../images/icons/v2/plus-20.svg');
}
&--private {
@include avatar('../images/icons/v2/group-solid-24.svg');
&--large {
height: 64px;
width: 64px;
}
}
}
&__title {
margin-left: 12px;
}
&__delete {
@include button-reset;
@include color-svg(
'../images/icons/v2/trash-outline-24.svg',
$color-gray-25
);
height: 20px;
width: 20px;
visibility: hidden;
}
&:hover &__delete {
visibility: visible;
}
}
&__divider {
border-color: $color-gray-65;
border-style: solid;
}
&__title {
@include font-body-1-bold;
margin-top: 24px;
}
&__delete-list {
@include button-reset;
align-items: center;
color: $color-accent-red;
display: flex;
height: 52px;
width: 100%;
&::before {
@include color-svg(
'../images/icons/v2/trash-outline-24.svg',
$color-accent-red
);
content: '';
height: 20px;
margin-right: 20px;
width: 20px;
}
}
&__checkbox {
margin: 18px 0;
}
&__conversation-list {
flex-grow: 1;
min-height: 300px;
overflow: hidden;
}
&__search {
&__container {
margin-left: 0;
margin-right: 0;
}
}
&__tags {
margin: 0 -4px;
}
&__tag {
align-items: center;
background: $color-gray-75;
border-radius: 26px;
color: $color-gray-05;
display: inline-flex;
padding: 4px 0;
margin: 0 4px;
&__name {
margin-left: 4px;
}
&__remove {
@include button-reset;
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-15);
height: 12px;
margin: 0 8px;
width: 12px;
}
}
&__name-story-avatar-container {
align-items: center;
display: flex;
justify-content: center;
}
&__disclaimer {
@include font-subtitle;
color: $color-gray-25;
&__learn-more {
@include button-reset;
color: $color-gray-05;
}
}
}

View file

@ -103,6 +103,7 @@
@import './components/SearchResultsLoadingFakeHeader.scss';
@import './components/SearchResultsLoadingFakeRow.scss';
@import './components/Select.scss';
@import './components/SignalConnectionsModal.scss';
@import './components/Slider.scss';
@import './components/StagedLinkPreview.scss';
@import './components/Stories.scss';
@ -110,6 +111,7 @@
@import './components/StoryImage.scss';
@import './components/StoryListItem.scss';
@import './components/StoryReplyQuote.scss';
@import './components/StoriesSettingsModal.scss';
@import './components/StoryViewsNRepliesModal.scss';
@import './components/StoryViewer.scss';
@import './components/SystemMessage.scss';

View file

@ -1,9 +1,8 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { Buffer } from 'buffer';
import pProps from 'p-props';
import { chunk } from 'lodash';
import Long from 'long';
import { HKDF } from '@signalapp/libsignal-client';
@ -15,6 +14,8 @@ import { ProfileDecryptError } from './types/errors';
import { UUID, UUID_BYTE_SIZE } from './types/UUID';
import type { UUIDStringType } from './types/UUID';
export { uuidToBytes } from './util/uuidToBytes';
export { HashType, CipherType };
const PROFILE_IV_LENGTH = 12; // bytes
@ -448,20 +449,6 @@ export async function encryptCdsDiscoveryRequest(
};
}
export function uuidToBytes(uuid: string): Uint8Array {
if (uuid.length !== 36) {
log.warn(
'uuidToBytes: received a string of invalid length. ' +
'Returning an empty Uint8Array'
);
return new Uint8Array(0);
}
return Uint8Array.from(
chunk(uuid.replace(/-/g, ''), 2).map(pair => parseInt(pair.join(''), 16))
);
}
export function bytesToUuid(bytes: Uint8Array): undefined | UUIDStringType {
if (bytes.byteLength !== UUID_BYTE_SIZE) {
log.warn(

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');
}

View file

@ -15,12 +15,16 @@ export async function loadDistributionLists(): Promise<void> {
export function getDistributionListsForRedux(): Array<StoryDistributionListDataType> {
strictAssert(distributionLists, 'distributionLists has not been loaded');
const lists = distributionLists.map(list => ({
allowsReplies: Boolean(list.allowsReplies),
id: list.id,
isBlockList: Boolean(list.isBlockList),
name: list.name,
}));
const lists = distributionLists
.map(list => ({
allowsReplies: Boolean(list.allowsReplies),
deletedAtTimestamp: list.deletedAtTimestamp,
id: list.id,
isBlockList: Boolean(list.isBlockList),
name: list.name,
memberUuids: list.members,
}))
.filter(list => !list.deletedAtTimestamp);
distributionLists = undefined;

View file

@ -52,6 +52,8 @@ import type {
UnknownRecord,
} from '../types/StorageService.d';
import MessageSender from '../textsecure/SendMessage';
import type { StoryDistributionWithMembersType } from '../sql/Interface';
import { MY_STORIES_ID } from '../types/Stories';
type IManifestRecordIdentifier = Proto.ManifestRecord.IIdentifier;
@ -328,12 +330,17 @@ async function generateManifest(
);
storyDistributionLists.forEach(storyDistributionList => {
const storageRecord = new Proto.StorageRecord();
storageRecord.storyDistributionList = toStoryDistributionListRecord(
storyDistributionList
);
const { isNewItem, storageID } = processStorageRecord({
currentStorageID: storyDistributionList.storageID,
currentStorageVersion: storyDistributionList.storageVersion,
identifierType: ITEM_TYPE.STORY_DISTRIBUTION_LIST,
storageNeedsSync: storyDistributionList.storageNeedsSync,
storageRecord: toStoryDistributionListRecord(storyDistributionList),
storageRecord,
});
if (isNewItem) {
@ -1018,6 +1025,35 @@ async function processManifest(
}
});
// Check to make sure we have a "My Stories" distribution list set up
const myStories = await dataInterface.getStoryDistributionWithMembers(
MY_STORIES_ID
);
if (!myStories) {
const storyDistribution: StoryDistributionWithMembersType = {
allowsReplies: true,
id: MY_STORIES_ID,
isBlockList: true,
members: [],
name: MY_STORIES_ID,
senderKeyInfo: undefined,
storageNeedsSync: true,
};
await dataInterface.createNewStoryDistribution(storyDistribution);
const shouldSave = false;
window.reduxActions.storyDistributionLists.createDistributionList(
storyDistribution.name,
storyDistribution.members,
storyDistribution,
shouldSave
);
conflictCount += 1;
}
log.info(
`storageService.process(${version}): conflictCount=${conflictCount}`
);

View file

@ -1277,12 +1277,14 @@ export async function mergeStoryDistributionListRecord(
if (!localStoryDistributionList) {
await dataInterface.createNewStoryDistribution(storyDistribution);
window.reduxActions.storyDistributionLists.createDistributionList({
allowsReplies: Boolean(storyDistribution.allowsReplies),
id: storyDistribution.id,
isBlockList: Boolean(storyDistribution.isBlockList),
name: storyDistribution.name,
});
const shouldSave = false;
window.reduxActions.storyDistributionLists.createDistributionList(
storyDistribution.name,
remoteListMembers,
storyDistribution,
shouldSave
);
return {
details,
@ -1306,8 +1308,6 @@ export async function mergeStoryDistributionListRecord(
storyDistributionListRecord
);
const needsUpdate = needsToClearUnknownFields || hasConflict;
const localMembersListSet = new Set(localStoryDistributionList.members);
const toAdd: Array<UUIDStringType> = remoteListMembers.filter(
uuid => !localMembersListSet.has(uuid)
@ -1319,6 +1319,10 @@ export async function mergeStoryDistributionListRecord(
uuid => !remoteMemberListSet.has(uuid)
);
const needsUpdate = Boolean(
needsToClearUnknownFields || hasConflict || toAdd.length || toRemove.length
);
if (!needsUpdate) {
return {
details: [...details, ...conflictDetails],
@ -1335,8 +1339,11 @@ export async function mergeStoryDistributionListRecord(
});
window.reduxActions.storyDistributionLists.modifyDistributionList({
allowsReplies: Boolean(storyDistribution.allowsReplies),
deletedAtTimestamp: storyDistribution.deletedAtTimestamp,
id: storyDistribution.id,
isBlockList: Boolean(storyDistribution.isBlockList),
membersToAdd: toAdd,
membersToRemove: toRemove,
name: storyDistribution.name,
});
}

View file

@ -240,8 +240,8 @@ export type StoryDistributionType = Readonly<{
isBlockList: boolean;
senderKeyInfo: SenderKeyInfoType | undefined;
storageID: string;
storageVersion: number;
storageID?: string;
storageVersion?: number;
storageUnknownFields?: Uint8Array | null;
storageNeedsSync: boolean;
}>;

View file

@ -4062,6 +4062,8 @@ type StoryDistributionForDatabase = Readonly<
deletedAtTimestamp: number | null;
isBlockList: 0 | 1;
senderKeyInfoJson: string | null;
storageID: string | null;
storageVersion: number | null;
storageNeedsSync: 0 | 1;
} & Omit<
StoryDistributionType,
@ -4069,6 +4071,8 @@ type StoryDistributionForDatabase = Readonly<
| 'deletedAtTimestamp'
| 'isBlockList'
| 'senderKeyInfo'
| 'storageID'
| 'storageVersion'
| 'storageNeedsSync'
>
>;
@ -4084,6 +4088,8 @@ function hydrateStoryDistribution(
senderKeyInfo: fromDatabase.senderKeyInfoJson
? JSON.parse(fromDatabase.senderKeyInfoJson)
: undefined,
storageID: fromDatabase.storageID || undefined,
storageVersion: fromDatabase.storageVersion || undefined,
storageNeedsSync: Boolean(fromDatabase.storageNeedsSync),
storageUnknownFields: fromDatabase.storageUnknownFields || undefined,
};
@ -4099,6 +4105,8 @@ function freezeStoryDistribution(
senderKeyInfoJson: story.senderKeyInfo
? JSON.stringify(story.senderKeyInfo)
: null,
storageID: story.storageID || null,
storageVersion: story.storageVersion || null,
storageNeedsSync: story.storageNeedsSync ? 1 : 0,
storageUnknownFields: story.storageUnknownFields || null,
};

View file

@ -16,6 +16,8 @@ export type GlobalModalsStateType = {
readonly contactModalState?: ContactModalStateType;
readonly forwardMessageProps?: ForwardMessagePropsType;
readonly isProfileEditorVisible: boolean;
readonly isStoriesSettingsVisible: boolean;
readonly isSignalConnectionsVisible: boolean;
readonly isWhatsNewVisible: boolean;
readonly profileEditorHasError: boolean;
readonly safetyNumberModalContactId?: string;
@ -30,12 +32,16 @@ const HIDE_WHATS_NEW_MODAL = 'globalModals/HIDE_WHATS_NEW_MODAL_MODAL';
const SHOW_WHATS_NEW_MODAL = 'globalModals/SHOW_WHATS_NEW_MODAL_MODAL';
const HIDE_UUID_NOT_FOUND_MODAL = 'globalModals/HIDE_UUID_NOT_FOUND_MODAL';
const SHOW_UUID_NOT_FOUND_MODAL = 'globalModals/SHOW_UUID_NOT_FOUND_MODAL';
const SHOW_STORIES_SETTINGS = 'globalModals/SHOW_STORIES_SETTINGS';
const HIDE_STORIES_SETTINGS = 'globalModals/HIDE_STORIES_SETTINGS';
const TOGGLE_FORWARD_MESSAGE_MODAL =
'globalModals/TOGGLE_FORWARD_MESSAGE_MODAL';
const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR';
export const TOGGLE_PROFILE_EDITOR_ERROR =
'globalModals/TOGGLE_PROFILE_EDITOR_ERROR';
const TOGGLE_SAFETY_NUMBER_MODAL = 'globalModals/TOGGLE_SAFETY_NUMBER_MODAL';
const TOGGLE_SIGNAL_CONNECTIONS_MODAL =
'globalModals/TOGGLE_SIGNAL_CONNECTIONS_MODAL';
export type ContactModalStateType = {
contactId: string;
@ -96,6 +102,18 @@ type ToggleSafetyNumberModalActionType = {
payload: string | undefined;
};
type ToggleSignalConnectionsModalActionType = {
type: typeof TOGGLE_SIGNAL_CONNECTIONS_MODAL;
};
type ShowStoriesSettingsActionType = {
type: typeof SHOW_STORIES_SETTINGS;
};
type HideStoriesSettingsActionType = {
type: typeof HIDE_STORIES_SETTINGS;
};
export type GlobalModalsActionType =
| HideContactModalActionType
| ShowContactModalActionType
@ -103,10 +121,13 @@ export type GlobalModalsActionType =
| ShowWhatsNewModalActionType
| HideUserNotFoundModalActionType
| ShowUserNotFoundModalActionType
| HideStoriesSettingsActionType
| ShowStoriesSettingsActionType
| ToggleForwardMessageModalActionType
| ToggleProfileEditorActionType
| ToggleProfileEditorErrorActionType
| ToggleSafetyNumberModalActionType;
| ToggleSafetyNumberModalActionType
| ToggleSignalConnectionsModalActionType;
// Action Creators
@ -117,10 +138,13 @@ export const actions = {
showWhatsNewModal,
hideUserNotFoundModal,
showUserNotFoundModal,
hideStoriesSettings,
showStoriesSettings,
toggleForwardMessageModal,
toggleProfileEditor,
toggleProfileEditorHasError,
toggleSafetyNumberModal,
toggleSignalConnectionsModal,
};
export const useGlobalModalActions = (): typeof actions =>
@ -172,6 +196,14 @@ function showUserNotFoundModal(
};
}
function hideStoriesSettings(): HideStoriesSettingsActionType {
return { type: HIDE_STORIES_SETTINGS };
}
function showStoriesSettings(): ShowStoriesSettingsActionType {
return { type: SHOW_STORIES_SETTINGS };
}
function toggleForwardMessageModal(
messageId?: string
): ThunkAction<
@ -224,13 +256,21 @@ function toggleSafetyNumberModal(
};
}
function toggleSignalConnectionsModal(): ToggleSignalConnectionsModalActionType {
return {
type: TOGGLE_SIGNAL_CONNECTIONS_MODAL,
};
}
// Reducer
export function getEmptyState(): GlobalModalsStateType {
return {
isProfileEditorVisible: false,
profileEditorHasError: false,
isSignalConnectionsVisible: false,
isStoriesSettingsVisible: false,
isWhatsNewVisible: false,
profileEditorHasError: false,
};
}
@ -310,5 +350,26 @@ export function reducer(
};
}
if (action.type === HIDE_STORIES_SETTINGS) {
return {
...state,
isStoriesSettingsVisible: false,
};
}
if (action.type === SHOW_STORIES_SETTINGS) {
return {
...state,
isStoriesSettingsVisible: true,
};
}
if (action.type === TOGGLE_SIGNAL_CONNECTIONS_MODAL) {
return {
...state,
isSignalConnectionsVisible: !state.isSignalConnectionsVisible,
};
}
return state;
}

View file

@ -1,16 +1,28 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ThunkAction } from 'redux-thunk';
import type { StateType as RootStateType } from '../reducer';
import type { StoryDistributionWithMembersType } from '../../sql/Interface';
import type { UUIDStringType } from '../../types/UUID';
import * as log from '../../logging/log';
import dataInterface from '../../sql/Client';
import { MY_STORIES_ID } from '../../types/Stories';
import { UUID } from '../../types/UUID';
import { replaceIndex } from '../../util/replaceIndex';
import { storageServiceUploadJob } from '../../services/storage';
import { useBoundActions } from '../../hooks/useBoundActions';
// State
export type StoryDistributionListDataType = {
id: UUIDStringType;
deletedAtTimestamp?: number;
name: string;
allowsReplies: boolean;
isBlockList: boolean;
memberUuids: Array<string>;
};
export type StoryDistributionListStateType = {
@ -19,36 +31,213 @@ export type StoryDistributionListStateType = {
// Actions
export const CREATE_LIST = 'storyDistributionLists/CREATE_LIST';
export const MODIFY_LIST = 'storyDistributionLists/MODIFY_LIST';
const ALLOW_REPLIES_CHANGED = 'storyDistributionLists/ALLOW_REPLIES_CHANGED';
const CREATE_LIST = 'storyDistributionLists/CREATE_LIST';
const DELETE_LIST = 'storyDistributionLists/DELETE_LIST';
const HIDE_MY_STORIES_FROM = 'storyDistributionLists/HIDE_MY_STORIES_FROM';
const MODIFY_LIST = 'storyDistributionLists/MODIFY_LIST';
const REMOVE_MEMBER = 'storyDistributionLists/REMOVE_MEMBER';
const RESET_MY_STORIES = 'storyDistributionLists/RESET_MY_STORIES';
const VIEWERS_CHANGED = 'storyDistributionLists/VIEWERS_CHANGED';
type AllowRepliesChangedActionType = {
type: typeof ALLOW_REPLIES_CHANGED;
payload: {
listId: string;
allowsReplies: boolean;
};
};
type CreateListActionType = {
type: typeof CREATE_LIST;
payload: StoryDistributionListDataType;
};
type DeleteListActionType = {
type: typeof DELETE_LIST;
payload: {
listId: string;
deletedAtTimestamp: number;
};
};
type HideMyStoriesFromActionType = {
type: typeof HIDE_MY_STORIES_FROM;
payload: Array<string>;
};
type ModifyDistributionListType = Omit<
StoryDistributionListDataType,
'memberUuids'
> & {
membersToAdd: Array<string>;
membersToRemove: Array<string>;
};
export type ModifyListActionType = {
type: typeof MODIFY_LIST;
payload: StoryDistributionListDataType;
payload: ModifyDistributionListType;
};
type RemoveMemberActionType = {
type: typeof REMOVE_MEMBER;
payload: {
listId: string;
memberUuid: string;
};
};
type ResetMyStoriesActionType = {
type: typeof RESET_MY_STORIES;
};
type ViewersChangedActionType = {
type: typeof VIEWERS_CHANGED;
payload: {
listId: string;
memberUuids: Array<string>;
};
};
type StoryDistributionListsActionType =
| AllowRepliesChangedActionType
| CreateListActionType
| ModifyListActionType;
| DeleteListActionType
| HideMyStoriesFromActionType
| ModifyListActionType
| RemoveMemberActionType
| ResetMyStoriesActionType
| ViewersChangedActionType;
// Action Creators
function allowsRepliesChanged(
listId: string,
allowsReplies: boolean
): ThunkAction<void, RootStateType, null, AllowRepliesChangedActionType> {
return async dispatch => {
const storyDistribution =
await dataInterface.getStoryDistributionWithMembers(listId);
if (!storyDistribution) {
log.warn(
'storyDistributionLists.allowsRepliesChanged: No story found for id',
listId
);
return;
}
if (storyDistribution.allowsReplies === allowsReplies) {
log.warn(
'storyDistributionLists.allowsRepliesChanged: story already has the same value',
{ listId, allowsReplies }
);
return;
}
await dataInterface.modifyStoryDistribution({
...storyDistribution,
allowsReplies,
storageNeedsSync: true,
});
storageServiceUploadJob();
log.info(
'storyDistributionLists.allowsRepliesChanged: allowsReplies has changed',
listId
);
dispatch({
type: ALLOW_REPLIES_CHANGED,
payload: {
listId,
allowsReplies,
},
});
};
}
function createDistributionList(
distributionList: StoryDistributionListDataType
): CreateListActionType {
return {
type: CREATE_LIST,
payload: distributionList,
name: string,
memberUuids: Array<UUIDStringType>,
storageServiceDistributionListRecord?: StoryDistributionWithMembersType,
shouldSave = true
): ThunkAction<void, RootStateType, null, CreateListActionType> {
return async dispatch => {
const storyDistribution: StoryDistributionWithMembersType = {
allowsReplies: true,
id: UUID.generate().toString(),
isBlockList: false,
members: memberUuids,
name,
senderKeyInfo: undefined,
storageNeedsSync: true,
...(storageServiceDistributionListRecord || {}),
};
if (shouldSave) {
await dataInterface.createNewStoryDistribution(storyDistribution);
}
if (storyDistribution.storageNeedsSync) {
storageServiceUploadJob();
}
dispatch({
type: CREATE_LIST,
payload: {
allowsReplies: Boolean(storyDistribution.allowsReplies),
deletedAtTimestamp: storyDistribution.deletedAtTimestamp,
id: storyDistribution.id,
isBlockList: Boolean(storyDistribution.isBlockList),
memberUuids,
name: storyDistribution.name,
},
});
};
}
function deleteDistributionList(
listId: string
): ThunkAction<void, RootStateType, unknown, DeleteListActionType> {
return async dispatch => {
const deletedAtTimestamp = Date.now();
const storyDistribution =
await dataInterface.getStoryDistributionWithMembers(listId);
if (!storyDistribution) {
log.warn('No story distribution found for id', listId);
return;
}
await dataInterface.modifyStoryDistribution({
...storyDistribution,
deletedAtTimestamp,
name: '',
storageNeedsSync: true,
});
log.info(
'storyDistributionLists.deleteDistributionList: list deleted',
listId
);
storageServiceUploadJob();
dispatch({
type: DELETE_LIST,
payload: {
listId,
deletedAtTimestamp,
},
});
};
}
function modifyDistributionList(
distributionList: StoryDistributionListDataType
distributionList: ModifyDistributionListType
): ModifyListActionType {
return {
type: MODIFY_LIST,
@ -56,9 +245,206 @@ function modifyDistributionList(
};
}
function hideMyStoriesFrom(
memberUuids: Array<UUIDStringType>
): ThunkAction<void, RootStateType, null, HideMyStoriesFromActionType> {
return async dispatch => {
const myStories = await dataInterface.getStoryDistributionWithMembers(
MY_STORIES_ID
);
if (!myStories) {
log.error(
'storyDistributionLists.hideMyStoriesFrom: Could not find My Stories!'
);
return;
}
const toAdd = new Set<UUIDStringType>(memberUuids);
await dataInterface.modifyStoryDistributionWithMembers(
{
...myStories,
isBlockList: true,
storageNeedsSync: true,
},
{
toAdd: Array.from(toAdd),
toRemove: myStories.members.filter(uuid => !toAdd.has(uuid)),
}
);
storageServiceUploadJob();
dispatch({
type: HIDE_MY_STORIES_FROM,
payload: memberUuids,
});
};
}
function removeMemberFromDistributionList(
listId: string,
memberUuid: UUIDStringType | undefined
): ThunkAction<void, RootStateType, null, RemoveMemberActionType> {
return async dispatch => {
if (!memberUuid) {
log.warn(
'storyDistributionLists.removeMemberFromDistributionList cannot remove a member without uuid',
listId
);
return;
}
const storyDistribution =
await dataInterface.getStoryDistributionWithMembers(listId);
if (!storyDistribution) {
log.warn(
'storyDistributionLists.removeMemberFromDistributionList: No story found for id',
listId
);
return;
}
await dataInterface.modifyStoryDistributionWithMembers(
{
...storyDistribution,
storageNeedsSync: true,
},
{
toAdd: [],
toRemove: [memberUuid],
}
);
log.info(
'storyDistributionLists.removeMemberFromDistributionList: removed',
{
listId,
memberUuid,
}
);
storageServiceUploadJob();
dispatch({
type: REMOVE_MEMBER,
payload: {
listId,
memberUuid,
},
});
};
}
function setMyStoriesToAllSignalConnections(): ThunkAction<
void,
RootStateType,
null,
ResetMyStoriesActionType
> {
return async dispatch => {
const myStories = await dataInterface.getStoryDistributionWithMembers(
MY_STORIES_ID
);
if (!myStories) {
log.error(
'storyDistributionLists.setMyStoriesToAllSignalConnections: Could not find My Stories!'
);
return;
}
if (myStories.isBlockList || myStories.members.length > 0) {
await dataInterface.modifyStoryDistributionWithMembers(
{
...myStories,
isBlockList: true,
storageNeedsSync: true,
},
{
toAdd: [],
toRemove: myStories.members,
}
);
storageServiceUploadJob();
}
dispatch({
type: RESET_MY_STORIES,
});
};
}
function updateStoryViewers(
listId: string,
memberUuids: Array<UUIDStringType>
): ThunkAction<void, RootStateType, null, ViewersChangedActionType> {
return async dispatch => {
const storyDistribution =
await dataInterface.getStoryDistributionWithMembers(listId);
if (!storyDistribution) {
log.warn(
'storyDistributionLists.updateStoryViewers: No story found for id',
listId
);
return;
}
const existingUuids = new Set<UUIDStringType>(storyDistribution.members);
const toAdd: Array<UUIDStringType> = [];
memberUuids.forEach(uuid => {
if (!existingUuids.has(uuid)) {
toAdd.push(uuid);
}
});
const updatedUuids = new Set<UUIDStringType>(memberUuids);
const toRemove: Array<UUIDStringType> = [];
storyDistribution.members.forEach(uuid => {
if (!updatedUuids.has(uuid)) {
toRemove.push(uuid);
}
});
await dataInterface.modifyStoryDistributionWithMembers(
{
...storyDistribution,
isBlockList: false,
storageNeedsSync: true,
},
{
toAdd,
toRemove,
}
);
storageServiceUploadJob();
dispatch({
type: VIEWERS_CHANGED,
payload: {
listId,
memberUuids,
},
});
};
}
export const actions = {
allowsRepliesChanged,
createDistributionList,
deleteDistributionList,
hideMyStoriesFrom,
modifyDistributionList,
removeMemberFromDistributionList,
setMyStoriesToAllSignalConnections,
updateStoryViewers,
};
export const useStoryDistributionListsActions = (): typeof actions =>
@ -72,23 +458,61 @@ export function getEmptyState(): StoryDistributionListStateType {
};
}
function replaceDistributionListData(
distributionLists: Array<StoryDistributionListDataType>,
listId: string,
getNextDistributionListData: (
list: StoryDistributionListDataType
) => Partial<StoryDistributionListDataType>
): Array<StoryDistributionListDataType> | undefined {
const listIndex = distributionLists.findIndex(list => list.id === listId);
if (listIndex < 0) {
return;
}
return replaceIndex(distributionLists, listIndex, {
...distributionLists[listIndex],
...getNextDistributionListData(distributionLists[listIndex]),
});
}
export function reducer(
state: Readonly<StoryDistributionListStateType> = getEmptyState(),
action: Readonly<StoryDistributionListsActionType>
): StoryDistributionListStateType {
if (action.type === MODIFY_LIST) {
const { payload } = action;
const distributionLists = [...state.distributionLists];
const existingList = distributionLists.find(list => list.id === payload.id);
if (existingList) {
Object.assign(existingList, payload);
} else {
distributionLists.concat(payload);
const { membersToAdd, membersToRemove, ...distributionListDetails } =
payload;
const listIndex = state.distributionLists.findIndex(
list => list.id === distributionListDetails.id
);
if (listIndex >= 0) {
const existingDistributionList = state.distributionLists[listIndex];
const memberUuids = new Set<string>(existingDistributionList.memberUuids);
membersToAdd.forEach(uuid => memberUuids.add(uuid));
membersToRemove.forEach(uuid => memberUuids.delete(uuid));
return {
distributionLists: replaceIndex(state.distributionLists, listIndex, {
...existingDistributionList,
...distributionListDetails,
memberUuids: Array.from(memberUuids),
}),
};
}
return {
distributionLists: [...distributionLists],
distributionLists: [
...state.distributionLists,
{
...distributionListDetails,
memberUuids: membersToAdd,
},
],
};
}
@ -98,5 +522,83 @@ export function reducer(
};
}
if (action.type === DELETE_LIST) {
const distributionLists = replaceDistributionListData(
state.distributionLists,
action.payload.listId,
() => ({
deletedAtTimestamp: action.payload.deletedAtTimestamp,
name: '',
})
);
return distributionLists ? { distributionLists } : state;
}
if (action.type === HIDE_MY_STORIES_FROM) {
const distributionLists = replaceDistributionListData(
state.distributionLists,
MY_STORIES_ID,
() => ({
isBlockList: true,
memberUuids: action.payload,
})
);
return distributionLists ? { distributionLists } : state;
}
if (action.type === REMOVE_MEMBER) {
const distributionLists = replaceDistributionListData(
state.distributionLists,
action.payload.listId,
list => ({
memberUuids: list.memberUuids.filter(
uuid => uuid !== action.payload.memberUuid
),
})
);
return distributionLists ? { distributionLists } : state;
}
if (action.type === ALLOW_REPLIES_CHANGED) {
const distributionLists = replaceDistributionListData(
state.distributionLists,
action.payload.listId,
() => ({
allowsReplies: action.payload.allowsReplies,
})
);
return distributionLists ? { distributionLists } : state;
}
if (action.type === VIEWERS_CHANGED) {
const distributionLists = replaceDistributionListData(
state.distributionLists,
action.payload.listId,
() => ({
isBlockList: false,
memberUuids: Array.from(new Set(action.payload.memberUuids)),
})
);
return distributionLists ? { distributionLists } : state;
}
if (action.type === RESET_MY_STORIES) {
const distributionLists = replaceDistributionListData(
state.distributionLists,
MY_STORIES_ID,
() => ({
isBlockList: false,
memberUuids: [],
})
);
return distributionLists ? { distributionLists } : state;
}
return state;
}

View file

@ -5,14 +5,31 @@ import { createSelector } from 'reselect';
import type { StateType } from '../reducer';
import type { StoryDistributionListDataType } from '../ducks/storyDistributionLists';
import type { StoryDistributionListWithMembersDataType } from '../../types/Stories';
import { getConversationSelector } from './conversations';
const getDistributionLists = (
export const getDistributionLists = (
state: StateType
): Array<StoryDistributionListDataType> =>
state.storyDistributionLists.distributionLists;
state.storyDistributionLists.distributionLists.filter(
list => !list.deletedAtTimestamp
);
export const getDistributionListSelector = createSelector(
getDistributionLists,
distributionLists => (id: string) =>
distributionLists.find(list => list.id === id)
);
export const getDistributionListsWithMembers = createSelector(
getConversationSelector,
getDistributionLists,
(
conversationSelector,
distributionLists
): Array<StoryDistributionListWithMembersDataType> =>
distributionLists.map(list => ({
...list,
members: list.memberUuids.map(uuid => conversationSelector(uuid)),
}))
);

View file

@ -10,6 +10,7 @@ import { SmartContactModal } from './ContactModal';
import { SmartForwardMessageModal } from './ForwardMessageModal';
import { SmartProfileEditorModal } from './ProfileEditorModal';
import { SmartSafetyNumberModal } from './SafetyNumberModal';
import { SmartStoriesSettingsModal } from './StoriesSettingsModal';
import { getIntl } from '../selectors/user';
@ -25,6 +26,10 @@ function renderForwardMessageModal(): JSX.Element {
return <SmartForwardMessageModal />;
}
function renderStoriesSettings(): JSX.Element {
return <SmartStoriesSettingsModal />;
}
const mapStateToProps = (state: StateType) => {
const i18n = getIntl(state);
@ -34,6 +39,7 @@ const mapStateToProps = (state: StateType) => {
renderContactModal,
renderForwardMessageModal,
renderProfileEditor,
renderStoriesSettings,
renderSafetyNumber: () => (
<SmartSafetyNumberModal
contactID={String(state.globalModals.safetyNumberModalContactId)}

View file

@ -27,7 +27,8 @@ function renderStoryCreator({
export function SmartStories(): JSX.Element | null {
const storiesActions = useStoriesActions();
const { showConversation, toggleHideStories } = useConversationsActions();
const { toggleForwardMessageModal } = useGlobalModalActions();
const { showStoriesSettings, toggleForwardMessageModal } =
useGlobalModalActions();
const i18n = useSelector<StateType, LocalizerType>(getIntl);
@ -64,6 +65,7 @@ export function SmartStories(): JSX.Element | null {
preferredWidthFromStorage={preferredWidthFromStorage}
renderStoryCreator={renderStoryCreator}
showConversation={showConversation}
showStoriesSettings={showStoriesSettings}
stories={stories}
toggleHideStories={toggleHideStories}
{...storiesActions}

View file

@ -0,0 +1,58 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { useSelector } from 'react-redux';
import type { LocalizerType } from '../../types/Util';
import type { StateType } from '../reducer';
import { StoriesSettingsModal } from '../../components/StoriesSettingsModal';
import {
getCandidateContactsForNewGroup,
getMe,
} from '../selectors/conversations';
import { getDistributionListsWithMembers } from '../selectors/storyDistributionLists';
import { getIntl } from '../selectors/user';
import { getPreferredBadgeSelector } from '../selectors/badges';
import { useGlobalModalActions } from '../ducks/globalModals';
import { useStoryDistributionListsActions } from '../ducks/storyDistributionLists';
export function SmartStoriesSettingsModal(): JSX.Element | null {
const { hideStoriesSettings, toggleSignalConnectionsModal } =
useGlobalModalActions();
const {
allowsRepliesChanged,
createDistributionList,
deleteDistributionList,
hideMyStoriesFrom,
removeMemberFromDistributionList,
setMyStoriesToAllSignalConnections,
updateStoryViewers,
} = useStoryDistributionListsActions();
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
const i18n = useSelector<StateType, LocalizerType>(getIntl);
const me = useSelector(getMe);
const candidateConversations = useSelector(getCandidateContactsForNewGroup);
const distributionLists = useSelector(getDistributionListsWithMembers);
return (
<StoriesSettingsModal
candidateConversations={candidateConversations}
distributionLists={distributionLists}
hideStoriesSettings={hideStoriesSettings}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
me={me}
onDeleteList={deleteDistributionList}
onDistributionListCreated={createDistributionList}
onHideMyStoriesFrom={hideMyStoriesFrom}
onRemoveMember={removeMemberFromDistributionList}
onRepliesNReactionsChanged={allowsRepliesChanged}
onViewersUpdated={updateStoryViewers}
setMyStoriesToAllSignalConnections={setMyStoriesToAllSignalConnections}
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
/>
);
}

View file

@ -9,6 +9,10 @@ import createDebug from 'debug';
import * as durations from '../../util/durations';
import { Bootstrap } from '../bootstrap';
import type { App } from '../bootstrap';
import { MY_STORIES_ID } from '../../types/Stories';
import { uuidToBytes } from '../../util/uuidToBytes';
const IdentifierType = Proto.ManifestRecord.Identifier.Type;
export const debug = createDebug('mock:test:gv2');
@ -58,6 +62,19 @@ describe('gv2', function needsName() {
givenName: 'PNI Contact',
});
state = state.addRecord({
type: IdentifierType.STORY_DISTRIBUTION_LIST,
record: {
storyDistributionList: {
allowsReplies: true,
identifier: uuidToBytes(MY_STORIES_ID),
isBlockList: true,
name: MY_STORIES_ID,
recipientUuids: [],
},
},
});
await phone.setStorageState(state);
app = await bootstrap.link();

View file

@ -7,6 +7,8 @@ import { StorageState, Proto } from '@signalapp/mock-server';
import { App } from '../playwright';
import { Bootstrap } from '../bootstrap';
import type { BootstrapOptions } from '../bootstrap';
import { MY_STORIES_ID } from '../../types/Stories';
import { uuidToBytes } from '../../util/uuidToBytes';
export const debug = createDebug('mock:test:storage');
@ -14,6 +16,8 @@ export { App, Bootstrap };
const GROUP_SIZE = 8;
const IdentifierType = Proto.ManifestRecord.Identifier.Type;
export type InitStorageResultType = Readonly<{
bootstrap: Bootstrap;
app: App;
@ -77,6 +81,19 @@ export async function initStorage(
state = state.pin(firstContact);
state = state.addRecord({
type: IdentifierType.STORY_DISTRIBUTION_LIST,
record: {
storyDistributionList: {
allowsReplies: true,
identifier: uuidToBytes(MY_STORIES_ID),
isBlockList: true,
name: MY_STORIES_ID,
recipientUuids: [],
},
},
});
await phone.setStorageState(state);
// Link new device

View file

@ -4,7 +4,9 @@
import type { AttachmentType } from './Attachment';
import type { ContactNameColorType } from './Colors';
import type { ConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from './Util';
import type { SendStatus } from '../messages/MessageSendState';
import type { StoryDistributionListDataType } from '../state/ducks/storyDistributionLists';
export type ReplyType = Pick<
ConversationType,
@ -110,3 +112,18 @@ export enum StoryViewModeType {
All = 'All',
Single = 'Single',
}
export type StoryDistributionListWithMembersDataType = Omit<
StoryDistributionListDataType,
'memberUuids'
> & {
members: Array<ConversationType>;
};
export function getStoryDistributionListName(
i18n: LocalizerType,
id: string,
name: string
): string {
return id === MY_STORIES_ID ? i18n('Stories__mine') : name;
}

View file

@ -0,0 +1,16 @@
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from './assert';
export function shouldNeverBeCalled(..._args: ReadonlyArray<unknown>): void {
assert(false, 'This should never be called. Doing nothing');
}
export async function asyncShouldNeverBeCalled(
..._args: ReadonlyArray<unknown>
): Promise<undefined> {
shouldNeverBeCalled();
return undefined;
}

19
ts/util/uuidToBytes.ts Normal file
View file

@ -0,0 +1,19 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { chunk } from 'lodash';
import * as log from '../logging/log';
export function uuidToBytes(uuid: string): Uint8Array {
if (uuid.length !== 36) {
log.warn(
'uuidToBytes: received a string of invalid length. ' +
'Returning an empty Uint8Array'
);
return new Uint8Array(0);
}
return Uint8Array.from(
chunk(uuid.replace(/-/g, ''), 2).map(pair => parseInt(pair.join(''), 16))
);
}

View file

@ -1753,10 +1753,10 @@
node-gyp-build "^4.2.3"
uuid "^8.3.0"
"@signalapp/mock-server@2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-2.0.1.tgz#0ecee7a0060181546e6b0c1b8e8c6f361fb2d7fe"
integrity sha512-YB0MIUzW8D1NirKpxxNXgEYuvK/OWbFo3djsBA4GqEUBIsJmdYcd4auHSqV3gKE/eSRoFQ0Z//eJNiqtsHbSEw==
"@signalapp/mock-server@2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-2.1.0.tgz#25e42aad9ec2bc76c92173e7894f1aec4c2bb719"
integrity sha512-AoeCRw8hOv4F+YQ6um/ZZiskaS1SsAXoQPgSMK69/xfDcPURJnVU6KB5Fy3chU2ZF0SZyWzS8vF3QguFKsIFWA==
dependencies:
"@signalapp/libsignal-client" "^0.18.1"
debug "^4.3.2"