diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 54875dd83..7539eda00 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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": "Learn more" + } + } + }, + "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" diff --git a/package.json b/package.json index e0004a1af..d6080235c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/stylesheets/components/ContextMenu.scss b/stylesheets/components/ContextMenu.scss index 6d7fdc5fa..2c208cf4f 100644 --- a/stylesheets/components/ContextMenu.scss +++ b/stylesheets/components/ContextMenu.scss @@ -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; + } } diff --git a/stylesheets/components/Modal.scss b/stylesheets/components/Modal.scss index ab17d4d68..25113eaf3 100644 --- a/stylesheets/components/Modal.scss +++ b/stylesheets/components/Modal.scss @@ -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; diff --git a/stylesheets/components/SignalConnectionsModal.scss b/stylesheets/components/SignalConnectionsModal.scss new file mode 100644 index 000000000..78732cd2b --- /dev/null +++ b/stylesheets/components/SignalConnectionsModal.scss @@ -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; + } + } +} diff --git a/stylesheets/components/Stories.scss b/stylesheets/components/Stories.scss index 7b015986c..487c7f8ff 100644 --- a/stylesheets/components/Stories.scss +++ b/stylesheets/components/Stories.scss @@ -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; diff --git a/stylesheets/components/StoriesSettingsModal.scss b/stylesheets/components/StoriesSettingsModal.scss new file mode 100644 index 000000000..9b69ef9ec --- /dev/null +++ b/stylesheets/components/StoriesSettingsModal.scss @@ -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; + } + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 0ca0bffc4..c9f4347ca 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -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'; diff --git a/ts/Crypto.ts b/ts/Crypto.ts index 370093f50..15e3d4303 100644 --- a/ts/Crypto.ts +++ b/ts/Crypto.ts @@ -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( diff --git a/ts/components/Checkbox.tsx b/ts/components/Checkbox.tsx index efa7dfec0..f4b941cbe 100644 --- a/ts/components/Checkbox.tsx +++ b/ts/components/Checkbox.tsx @@ -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'} />
- -
{description}
+
diff --git a/ts/components/ContextMenu.tsx b/ts/components/ContextMenu.tsx index 6818b4219..befcbe7f5 100644 --- a/ts/components/ContextMenu.tsx +++ b/ts/components/ContextMenu.tsx @@ -93,7 +93,9 @@ export function ContextMenuPopper({ >
; @@ -470,15 +473,3 @@ export const ForwardMessageModal: FunctionComponent = ({ ); }; - -function shouldNeverBeCalled(..._args: ReadonlyArray): void { - assert(false, 'This should never be called. Doing nothing'); -} - -async function asyncShouldNeverBeCalled( - ..._args: ReadonlyArray -): Promise { - shouldNeverBeCalled(); - - return undefined; -} diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx index 66c28dc8d..3132bd369 100644 --- a/ts/components/GlobalModalContainer.tsx +++ b/ts/components/GlobalModalContainer.tsx @@ -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 ( + + ); + } + + if (isStoriesSettingsVisible) { + return renderStoriesSettings(); + } + return null; }; diff --git a/ts/components/Modal.stories.tsx b/ts/components/Modal.stories.tsx index ee9d28aa6..a9d443f8a 100644 --- a/ts/components/Modal.stories.tsx +++ b/ts/components/Modal.stories.tsx @@ -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 => ( - Hello world! + + Hello world! + ); BareBonesShort.story = { @@ -31,7 +33,7 @@ BareBonesShort.story = { }; export const BareBonesLong = (): JSX.Element => ( - +

{LOREM_IPSUM}

{LOREM_IPSUM}

{LOREM_IPSUM}

@@ -96,7 +98,7 @@ LotsOfButtonsInTheFooter.story = { }; export const LongBodyWithTitle = (): JSX.Element => ( - +

{LOREM_IPSUM}

{LOREM_IPSUM}

{LOREM_IPSUM}

@@ -195,3 +197,19 @@ export const StickyFooterLotsOfButtons = (): JSX.Element => ( StickyFooterLotsOfButtons.story = { name: 'Sticky footer, Lots of buttons', }; + +export const WithBackButton = (): JSX.Element => ( + + Hello world! + +); + +WithBackButton.story = { + name: 'Back Button', +}; diff --git a/ts/components/Modal.tsx b/ts/components/Modal.tsx index 7423f03e2..4ee7afb1f 100644 --- a/ts/components/Modal.tsx +++ b/ts/components/Modal.tsx @@ -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): 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 && ( -
- {hasXButton && ( +
+ {onBackButtonClick && (
)} diff --git a/ts/components/MyStories.tsx b/ts/components/MyStories.tsx index e0e40d5d9..6728d38e0 100644 --- a/ts/components/MyStories.tsx +++ b/ts/components/MyStories.tsx @@ -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 => (
- {list.distributionId === MY_STORIES_ID - ? i18n('Stories__mine') - : list.distributionName} +
{list.stories.map(story => (
diff --git a/ts/components/SignalConnectionsModal.stories.tsx b/ts/components/SignalConnectionsModal.stories.tsx new file mode 100644 index 000000000..49adcd1ba --- /dev/null +++ b/ts/components/SignalConnectionsModal.stories.tsx @@ -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 = args => ; + +export const Modal = Template.bind({}); +Modal.args = {}; diff --git a/ts/components/SignalConnectionsModal.tsx b/ts/components/SignalConnectionsModal.tsx new file mode 100644 index 000000000..93c3d8a81 --- /dev/null +++ b/ts/components/SignalConnectionsModal.tsx @@ -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 ( + +
+ + +
+ {i18n('SignalConnectionsModal__title')} + ), + }} + i18n={i18n} + id="SignalConnectionsModal__header" + /> +
+ +
    +
  • {i18n('SignalConnectionsModal__bullet--1')}
  • +
  • {i18n('SignalConnectionsModal__bullet--2')}
  • +
  • {i18n('SignalConnectionsModal__bullet--3')}
  • +
+ +
+ {i18n('SignalConnectionsModal__footer')} +
+ +
+ +
+
+
+ ); +}; diff --git a/ts/components/Stories.stories.tsx b/ts/components/Stories.stories.tsx index b54c7a7bb..42f33522f 100644 --- a/ts/components/Stories.stories.tsx +++ b/ts/components/Stories.stories.tsx @@ -46,6 +46,7 @@ export default { renderStoryCreator: { action: true }, renderStoryViewer: { action: true }, showConversation: { action: true }, + showStoriesSettings: { action: true }, stories: { defaultValue: [], }, diff --git a/ts/components/Stories.tsx b/ts/components/Stories.tsx index e82335c7d..d5a5a7fef 100644 --- a/ts/components/Stories.tsx +++ b/ts/components/Stories.tsx @@ -33,6 +33,7 @@ export type PropsType = { queueStoryDownload: (storyId: string) => unknown; renderStoryCreator: (props: SmartStoryCreatorPropsType) => JSX.Element; showConversation: ShowConversationType; + showStoriesSettings: () => unknown; stories: Array; 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} diff --git a/ts/components/StoriesPane.tsx b/ts/components/StoriesPane.tsx index 8dbaa5874..f3f9be6a2 100644 --- a/ts/components/StoriesPane.tsx +++ b/ts/components/StoriesPane.tsx @@ -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 = { @@ -63,6 +65,7 @@ export type PropsType = { myStories: Array; 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" /> + onStoriesSettings(), + label: i18n('StoriesSettings__context-menu'), + }, + ]} + popperOptions={{ + placement: 'bottom', + strategy: 'absolute', + }} + theme={Theme.Dark} + />
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 = 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', + }, + ], +}; diff --git a/ts/components/StoriesSettingsModal.tsx b/ts/components/StoriesSettingsModal.tsx new file mode 100644 index 000000000..88770ec27 --- /dev/null +++ b/ts/components/StoriesSettingsModal.tsx @@ -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; + distributionLists: Array; + getPreferredBadge: PreferredBadgeSelectorType; + hideStoriesSettings: () => unknown; + i18n: LocalizerType; + me: ConversationType; + onDeleteList: (listId: string) => unknown; + onDistributionListCreated: ( + name: string, + viewerUuids: Array + ) => unknown; + onHideMyStoriesFrom: (viewerUuids: Array) => unknown; + onRemoveMember: (listId: string, uuid: UUIDStringType | undefined) => unknown; + onRepliesNReactionsChanged: ( + listId: string, + allowsReplies: boolean + ) => unknown; + onViewersUpdated: ( + listId: string, + viewerUuids: Array + ) => 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( + undefined + ); + + const listToEdit = useMemo( + () => distributionLists.find(x => x.id === listToEditId), + [distributionLists, listToEditId] + ); + + const [page, setPage] = useState(Page.DistributionLists); + + const [storyName, setStoryName] = useState(''); + + const [searchTerm, setSearchTerm] = useState(''); + + const [filteredConversations, setFilteredConversations] = useState( + filterAndSortConversationsByRecent( + candidateConversations, + searchTerm, + undefined + ) + ); + + const [selectedContacts, setSelectedContacts] = useState< + Array + >([]); + + 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 = 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 = ( + <> +
+
+
+ + + +
+ {i18n('StoriesSettings__who-can-see')} +
+ + {selectedContacts.map(contact => ( +
+ + + + {contact.title} + + +
+ ))} + + ); + } 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 = ( + <> + { + setSearchTerm(event.target.value); + }} + value={searchTerm} + /> + {selectedContacts.length ? ( +
+ {selectedContacts.map(contact => ( +
+ + + {contact.firstName || + contact.profileName || + contact.phoneNumber} + +
+ ))} +
+ ) : undefined} + {candidateConversations.length ? ( + + {({ contentRect, measureRef }: MeasuredComponentProps) => ( +
+ { + toggleSelectedConversation(conversationId); + }} + lookupConversationWithoutUuid={asyncShouldNeverBeCalled} + showConversation={shouldNeverBeCalled} + showUserNotFoundModal={shouldNeverBeCalled} + setIsFetchingUUID={shouldNeverBeCalled} + onSelectConversation={shouldNeverBeCalled} + renderMessageSearchResult={() => { + shouldNeverBeCalled(); + return
; + }} + rowCount={rowCount} + shouldRecomputeRowHeights={false} + showChooseGroupMembers={shouldNeverBeCalled} + theme={ThemeType.dark} + /> +
+ )} + + ) : ( +
+ {i18n('noContactsFound')} +
+ )} + + ); + } else if (listToEdit) { + const isMyStories = listToEdit.id === MY_STORIES_ID; + + content = ( + <> + {!isMyStories && ( + <> +
+ + + + + + +
+ +
+ + )} + +
+ {i18n('StoriesSettings__who-can-see')} +
+ + {isMyStories && ( + <> + { + setMyStoriesToAllSignalConnections(); + }} + /> + + 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); + }} + /> + + 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); + }} + /> + +
+ + {i18n('StoriesSettings__mine__disclaimer--learn-more')} + + ), + }} + i18n={i18n} + id="StoriesSettings__mine__disclaimer" + /> +
+ + )} + + {!isMyStories && ( + <> + + + {listToEdit.members.map(member => ( +
+ + + + {member.title} + + + +
+ ))} + + )} + +
+ +
+ {i18n('StoriesSettings__replies-reactions--title')} +
+ + onRepliesNReactionsChanged(listToEdit.id, value)} + /> + + {!isMyStories && ( + <> +
+ + + + )} + + ); + } else { + const privateStories = distributionLists.filter( + list => list.id !== MY_STORIES_ID + ); + + content = ( + <> + + +
+ + + {privateStories.map(list => ( + + ))} + + ); + } + + 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 ( + <> + { + 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 && ( + + + + )} + {page === Page.NameStory && ( + + + + )} + {page === Page.HideStoryFrom && ( + + + + )} + + {confirmDeleteListId && ( + { + onDeleteList(confirmDeleteListId); + setListToEditId(undefined); + }, + style: 'negative', + text: i18n('delete'), + }, + ]} + i18n={i18n} + onClose={() => { + setConfirmDeleteListId(undefined); + }} + > + {i18n('StoriesSettings__delete-list--confirm')} + + )} + {confirmRemoveMember && ( + + 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')} + + )} + + ); +}; diff --git a/ts/components/StoryDistributionListName.tsx b/ts/components/StoryDistributionListName.tsx new file mode 100644 index 000000000..4f2029688 --- /dev/null +++ b/ts/components/StoryDistributionListName.tsx @@ -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)}; +}; diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx index c9e00163b..1150915c5 100644 --- a/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx @@ -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 = ({ ); }; - -function shouldNeverBeCalled(..._args: ReadonlyArray): unknown { - assert(false, 'This should never be called. Doing nothing'); -} diff --git a/ts/services/distributionListLoader.ts b/ts/services/distributionListLoader.ts index b81bb5708..ab7019c88 100644 --- a/ts/services/distributionListLoader.ts +++ b/ts/services/distributionListLoader.ts @@ -15,12 +15,16 @@ export async function loadDistributionLists(): Promise { export function getDistributionListsForRedux(): Array { 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; diff --git a/ts/services/storage.ts b/ts/services/storage.ts index 8644ac7b5..0e61a56f3 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -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}` ); diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 93bdd64dd..f0418fbef 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -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 = 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, }); } diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index f6c7bcdb3..caa9ae67d 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -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; }>; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 34f05aed5..59f157bea 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -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, }; diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index 232c42437..e46745152 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -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; } diff --git a/ts/state/ducks/storyDistributionLists.ts b/ts/state/ducks/storyDistributionLists.ts index 0e7ea1a42..4a1a0d979 100644 --- a/ts/state/ducks/storyDistributionLists.ts +++ b/ts/state/ducks/storyDistributionLists.ts @@ -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; }; 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; +}; + +type ModifyDistributionListType = Omit< + StoryDistributionListDataType, + 'memberUuids' +> & { + membersToAdd: Array; + membersToRemove: Array; +}; + 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; + }; }; type StoryDistributionListsActionType = + | AllowRepliesChangedActionType | CreateListActionType - | ModifyListActionType; + | DeleteListActionType + | HideMyStoriesFromActionType + | ModifyListActionType + | RemoveMemberActionType + | ResetMyStoriesActionType + | ViewersChangedActionType; // Action Creators +function allowsRepliesChanged( + listId: string, + allowsReplies: boolean +): ThunkAction { + 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, + storageServiceDistributionListRecord?: StoryDistributionWithMembersType, + shouldSave = true +): ThunkAction { + 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 { + 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 +): ThunkAction { + 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(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 { + 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 +): ThunkAction { + 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(storyDistribution.members); + const toAdd: Array = []; + + memberUuids.forEach(uuid => { + if (!existingUuids.has(uuid)) { + toAdd.push(uuid); + } + }); + + const updatedUuids = new Set(memberUuids); + const toRemove: Array = []; + + 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, + listId: string, + getNextDistributionListData: ( + list: StoryDistributionListDataType + ) => Partial +): Array | 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 = getEmptyState(), action: Readonly ): 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(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; } diff --git a/ts/state/selectors/storyDistributionLists.ts b/ts/state/selectors/storyDistributionLists.ts index 630f00757..da96424a6 100644 --- a/ts/state/selectors/storyDistributionLists.ts +++ b/ts/state/selectors/storyDistributionLists.ts @@ -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 => - 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 => + distributionLists.map(list => ({ + ...list, + members: list.memberUuids.map(uuid => conversationSelector(uuid)), + })) +); diff --git a/ts/state/smart/GlobalModalContainer.tsx b/ts/state/smart/GlobalModalContainer.tsx index 6a8cb10d2..f454715d5 100644 --- a/ts/state/smart/GlobalModalContainer.tsx +++ b/ts/state/smart/GlobalModalContainer.tsx @@ -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 ; } +function renderStoriesSettings(): JSX.Element { + return ; +} + const mapStateToProps = (state: StateType) => { const i18n = getIntl(state); @@ -34,6 +39,7 @@ const mapStateToProps = (state: StateType) => { renderContactModal, renderForwardMessageModal, renderProfileEditor, + renderStoriesSettings, renderSafetyNumber: () => ( (getIntl); @@ -64,6 +65,7 @@ export function SmartStories(): JSX.Element | null { preferredWidthFromStorage={preferredWidthFromStorage} renderStoryCreator={renderStoryCreator} showConversation={showConversation} + showStoriesSettings={showStoriesSettings} stories={stories} toggleHideStories={toggleHideStories} {...storiesActions} diff --git a/ts/state/smart/StoriesSettingsModal.tsx b/ts/state/smart/StoriesSettingsModal.tsx new file mode 100644 index 000000000..e759d79b6 --- /dev/null +++ b/ts/state/smart/StoriesSettingsModal.tsx @@ -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(getIntl); + const me = useSelector(getMe); + + const candidateConversations = useSelector(getCandidateContactsForNewGroup); + const distributionLists = useSelector(getDistributionListsWithMembers); + + return ( + + ); +} diff --git a/ts/test-mock/gv2/create_test.ts b/ts/test-mock/gv2/create_test.ts index 2db101256..38c90185b 100644 --- a/ts/test-mock/gv2/create_test.ts +++ b/ts/test-mock/gv2/create_test.ts @@ -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(); diff --git a/ts/test-mock/storage/fixtures.ts b/ts/test-mock/storage/fixtures.ts index b47974b58..dd4af9f9f 100644 --- a/ts/test-mock/storage/fixtures.ts +++ b/ts/test-mock/storage/fixtures.ts @@ -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 diff --git a/ts/types/Stories.ts b/ts/types/Stories.ts index 54d5b2314..68c236c0e 100644 --- a/ts/types/Stories.ts +++ b/ts/types/Stories.ts @@ -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; +}; + +export function getStoryDistributionListName( + i18n: LocalizerType, + id: string, + name: string +): string { + return id === MY_STORIES_ID ? i18n('Stories__mine') : name; +} diff --git a/ts/util/shouldNeverBeCalled.ts b/ts/util/shouldNeverBeCalled.ts new file mode 100644 index 000000000..d07ecb574 --- /dev/null +++ b/ts/util/shouldNeverBeCalled.ts @@ -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): void { + assert(false, 'This should never be called. Doing nothing'); +} + +export async function asyncShouldNeverBeCalled( + ..._args: ReadonlyArray +): Promise { + shouldNeverBeCalled(); + + return undefined; +} diff --git a/ts/util/uuidToBytes.ts b/ts/util/uuidToBytes.ts new file mode 100644 index 000000000..882351aa1 --- /dev/null +++ b/ts/util/uuidToBytes.ts @@ -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)) + ); +} diff --git a/yarn.lock b/yarn.lock index 236ecb525..c9ad33488 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"