From ccc89545c5826d05071d6cdbffa99fa677d76808 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Wed, 10 Aug 2022 14:37:19 -0400 Subject: [PATCH] Lets users send stories to groups --- _locales/en/messages.json | 24 + stylesheets/components/ContextMenu.scss | 4 +- stylesheets/components/Modal.scss | 1 + stylesheets/components/SendStoryModal.scss | 141 ++++- .../components/StoriesSettingsModal.scss | 80 ++- ts/components/MediaEditor.tsx | 4 +- ts/components/Modal.tsx | 7 +- ts/components/SendStoryModal.stories.tsx | 17 +- ts/components/SendStoryModal.tsx | 516 +++++++++++++--- ts/components/StoriesSettingsModal.tsx | 578 ++++++++++-------- ts/components/StoryCreator.stories.tsx | 20 +- ts/components/StoryCreator.tsx | 27 +- .../BaseConversationListItem.tsx | 31 +- ts/jobs/helpers/sendStory.ts | 19 +- ts/model-types.d.ts | 1 + ts/models/conversations.ts | 1 + ts/state/ducks/conversations.ts | 25 + ts/state/selectors/conversations.ts | 14 + ts/state/selectors/storyDistributionLists.ts | 7 +- ts/state/smart/StoryCreator.tsx | 27 +- .../helpers/getDefaultConversation.ts | 33 + 21 files changed, 1177 insertions(+), 400 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 0278777a0..5f518f25d 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -7381,6 +7381,30 @@ "message": "Send story", "description": "aria-label for the send story button" }, + "SendStoryModal__new": { + "message": "New story", + "description": "button to create a new distribution list to send story to" + }, + "SendStoryModal__new-private--title": { + "message": "New private story", + "description": "Create a new distribution list" + }, + "SendStoryModal__new-private--description": { + "message": "Visible only to specific people", + "description": "Description of what a distribution list would do" + }, + "SendStoryModal__new-group--title": { + "message": "New group story", + "description": "Select a group to send a story to" + }, + "SendStoryModal__new-group--description": { + "message": "Share to an existing group", + "description": "Description of what selecting a group would do" + }, + "SendStoryModal__choose-groups": { + "message": "Choose groups", + "description": "Modal title when choosing groups" + }, "Stories__settings-toggle--title": { "message": "Share & View Stories", "description": "Select box title for the stories on/off toggle" diff --git a/stylesheets/components/ContextMenu.scss b/stylesheets/components/ContextMenu.scss index 995ed1313..29e22dd9f 100644 --- a/stylesheets/components/ContextMenu.scss +++ b/stylesheets/components/ContextMenu.scss @@ -11,7 +11,7 @@ &__popper { @extend %module-composition-popper; margin: 0; - padding: 6px 0; + padding: 6px 2px; width: auto; &--single-item { @@ -40,8 +40,8 @@ display: flex; justify-content: space-between; padding: 6px; - margin: 0 2px; min-width: 150px; + width: 100%; &--container { display: flex; diff --git a/stylesheets/components/Modal.scss b/stylesheets/components/Modal.scss index d188b46f4..76d133b99 100644 --- a/stylesheets/components/Modal.scss +++ b/stylesheets/components/Modal.scss @@ -132,6 +132,7 @@ } &__body { + @include scrollbar; @include font-body-1; margin: 0; } diff --git a/stylesheets/components/SendStoryModal.scss b/stylesheets/components/SendStoryModal.scss index 4a4c3bf67..f0e4fc0fe 100644 --- a/stylesheets/components/SendStoryModal.scss +++ b/stylesheets/components/SendStoryModal.scss @@ -2,6 +2,52 @@ // SPDX-License-Identifier: AGPL-3.0-only .SendStoryModal { + &__top-bar { + align-items: center; + display: flex; + min-height: 40px; + justify-content: space-between; + user-select: none; + } + + &__new-story { + &__container { + &::before { + @include color-svg('../images/icons/v2/plus-20.svg', $color-white); + content: ''; + height: 16px; + margin-right: 8px; + width: 16px; + } + } + + &__option--description { + color: $color-gray-25; + } + } + + &__icon { + &--lock { + @include color-svg( + '../images/icons/v2/lock-outline-24.svg', + $color-white + ); + height: 14px; + margin-top: 4px; + width: 11px; + } + + &--group { + @include color-svg( + '../images/icons/v2/group-outline-24.svg', + $color-white + ); + height: 14px; + margin-top: 2px; + width: 14px; + } + } + &__distribution-list { &__container { justify-content: space-between; @@ -29,29 +75,98 @@ @include font-body-2; color: $color-gray-60; } - } - &__button-footer { - align-items: center; - justify-content: space-between; + &__checkbox { + margin-right: 0; + position: relative; + } + + &__checkbox input[type='checkbox'] { + cursor: pointer; + height: 0; + position: absolute; + width: 0; + + @include keyboard-mode { + &:focus { + &::before { + border-color: $color-ultramarine; + } + outline: none; + } + } + + &::before { + @include rounded-corners; + background: inherit; + border: 1.5px solid $color-gray-60; + content: ''; + display: block; + height: 20px; + position: absolute; + width: 20px; + } + + &:checked { + &::before { + background: $color-ultramarine; + border: 1.5px solid $color-ultramarine; + } + + &::after { + border: solid $color-white; + border-width: 0 2px 2px 0; + content: ''; + display: block; + height: 11px; + left: 7px; + position: absolute; + top: 3px; + transform: rotate(45deg); + width: 6px; + } + } + } } &__selected-lists { @include font-body-2; - color: $color-gray-60; + color: $color-gray-15; max-width: 280px; user-select: none; } + &__ok { + @include button-reset; + @include rounded-corners; + align-items: center; + background: $color-ultramarine; + display: flex; + height: 32px; + justify-content: center; + width: 32px; + + &::disabled { + background: $color-gray-60; + } + + &::after { + @include color-svg('../images/icons/v2/check-24.svg', $color-white); + content: ''; + height: 18px; + width: 18px; + } + } + &__send { @include button-reset; @include rounded-corners; align-items: center; background: $color-ultramarine; display: flex; - height: 40px; + height: 32px; justify-content: center; - width: 40px; + width: 32px; &::disabled { background: $color-gray-60; @@ -60,8 +175,16 @@ &::after { @include color-svg('../images/icons/v2/send-24.svg', $color-white); content: ''; - height: 24px; - width: 24px; + height: 18px; + width: 18px; } } } + +.module-Modal--sticky-buttons .SendStoryModal__button-footer { + align-items: center; + justify-content: space-between; + padding-top: 0; + padding-left: 16px; + padding-right: 16px; +} diff --git a/stylesheets/components/StoriesSettingsModal.scss b/stylesheets/components/StoriesSettingsModal.scss index 9b69ef9ec..a2f7448e9 100644 --- a/stylesheets/components/StoriesSettingsModal.scss +++ b/stylesheets/components/StoriesSettingsModal.scss @@ -2,13 +2,71 @@ // SPDX-License-Identifier: AGPL-3.0-only .StoriesSettingsModal { - &__modal { - .module-conversation-list { - padding: 0; + &__conversation-list { + .module-conversation-list, + .module-conversation-list__item--contact-or-conversation { + padding-left: 0; + padding-right: 0; } - .module-conversation-list__item--contact-or-conversation { - padding: 0; + .module-conversation-list__item--contact-or-conversation__checkbox--container { + height: 20px; + margin-right: 8px; + position: relative; + width: 20px; + } + + input[type='checkbox'] { + background: transparent; + border: none; + cursor: pointer; + display: block; + height: 0; + margin: 0; + min-width: 0; + position: absolute; + width: 0; + + @include keyboard-mode { + &:focus { + &::before { + border-color: $color-ultramarine; + } + outline: none; + } + } + + &::before { + @include rounded-corners; + background: inherit; + border: 1.5px solid $color-gray-60; + content: ''; + display: block; + height: 20px; + position: absolute; + width: 20px; + } + + &:checked { + &::before { + -webkit-mask: none; + background: $color-ultramarine; + border: 1.5px solid $color-ultramarine; + } + + &::after { + border: solid $color-white; + border-width: 0 2px 2px 0; + content: ''; + display: block; + height: 11px; + left: 7px; + position: absolute; + top: 3px; + transform: rotate(45deg); + width: 6px; + } + } } } @@ -57,11 +115,21 @@ } &--private { - @include avatar('../images/icons/v2/group-solid-24.svg'); + @include avatar('../images/icons/v2/lock-outline-24.svg'); + + &::after { + height: 16px; + width: 12px; + } &--large { height: 64px; width: 64px; + + &::after { + height: 24px; + width: 18px; + } } } } diff --git a/ts/components/MediaEditor.tsx b/ts/components/MediaEditor.tsx index 18f69730d..9a39ebe68 100644 --- a/ts/components/MediaEditor.tsx +++ b/ts/components/MediaEditor.tsx @@ -35,6 +35,7 @@ import { } from '../mediaEditor/util/getTextStyleAttributes'; export type PropsType = { + doneButtonLabel?: string; i18n: LocalizerType; imageSrc: string; onClose: () => unknown; @@ -84,6 +85,7 @@ function isCmdOrCtrl(ev: KeyboardEvent): boolean { } export const MediaEditor = ({ + doneButtonLabel, i18n, imageSrc, onClose, @@ -1065,7 +1067,7 @@ export const MediaEditor = ({ theme={Theme.Dark} variant={ButtonVariant.Primary} > - {i18n('save')} + {doneButtonLabel || i18n('save')} diff --git a/ts/components/Modal.tsx b/ts/components/Modal.tsx index 4ee7afb1f..8bc648965 100644 --- a/ts/components/Modal.tsx +++ b/ts/components/Modal.tsx @@ -22,6 +22,7 @@ type PropsType = { hasStickyButtons?: boolean; hasXButton?: boolean; i18n: LocalizerType; + modalFooter?: JSX.Element; moduleClassName?: string; onBackButtonClick?: () => unknown; onClose?: () => void; @@ -41,12 +42,13 @@ export function Modal({ hasStickyButtons, hasXButton, i18n, + modalFooter, moduleClassName, noMouseClose, onBackButtonClick, onClose = noop, - title, theme, + title, useFocusTrap, }: Readonly): ReactElement { const { close, modalStyles, overlayStyles } = useAnimated(onClose, { @@ -71,6 +73,7 @@ export function Modal({ hasStickyButtons={hasStickyButtons} hasXButton={hasXButton} i18n={i18n} + modalFooter={modalFooter} moduleClassName={moduleClassName} onBackButtonClick={onBackButtonClick} onClose={close} @@ -88,6 +91,7 @@ export function ModalWindow({ hasStickyButtons, hasXButton, i18n, + modalFooter, moduleClassName, onBackButtonClick, onClose = noop, @@ -192,6 +196,7 @@ export function ModalWindow({ )} + {modalFooter} ); diff --git a/ts/components/SendStoryModal.stories.tsx b/ts/components/SendStoryModal.stories.tsx index d7acaf615..50b865d99 100644 --- a/ts/components/SendStoryModal.stories.tsx +++ b/ts/components/SendStoryModal.stories.tsx @@ -7,7 +7,10 @@ import React from 'react'; import type { PropsType } from './SendStoryModal'; import enMessages from '../../_locales/en/messages.json'; import { SendStoryModal } from './SendStoryModal'; -import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; +import { + getDefaultConversation, + getDefaultGroup, +} from '../test-both/helpers/getDefaultConversation'; import { setupI18n } from '../util/setupI18n'; import { getMyStories, @@ -20,9 +23,19 @@ export default { title: 'Components/SendStoryModal', component: SendStoryModal, argTypes: { + candidateConversations: { + defaultValue: Array.from(Array(100), () => getDefaultConversation()), + }, distributionLists: { defaultValue: [getMyStories()], }, + getPreferredBadge: { action: true }, + groupConversations: { + defaultValue: Array.from(Array(7), getDefaultGroup), + }, + groupStories: { + defaultValue: Array.from(Array(2), getDefaultGroup), + }, i18n: { defaultValue: i18n, }, @@ -30,10 +43,12 @@ export default { defaultValue: getDefaultConversation(), }, onClose: { action: true }, + onDistributionListCreated: { action: true }, onSend: { action: true }, signalConnections: { defaultValue: Array.from(Array(42), getDefaultConversation), }, + tagGroupsAsNewGroupStory: { action: true }, }, } as Meta; diff --git a/ts/components/SendStoryModal.tsx b/ts/components/SendStoryModal.tsx index 0a5f9036f..4013a1bf4 100644 --- a/ts/components/SendStoryModal.tsx +++ b/ts/components/SendStoryModal.tsx @@ -1,27 +1,61 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; + +import { SearchInput } from './SearchInput'; +import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations'; import type { ConversationType } from '../state/ducks/conversations'; import type { LocalizerType } from '../types/Util'; +import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { StoryDistributionListDataType } from '../state/ducks/storyDistributionLists'; import type { UUIDStringType } from '../types/UUID'; import { Avatar, AvatarSize } from './Avatar'; import { Checkbox } from './Checkbox'; +import { ContextMenu } from './ContextMenu'; +import { + EditDistributionList, + Page as StoriesSettingsPage, +} from './StoriesSettingsModal'; import { MY_STORIES_ID, getStoryDistributionListName } from '../types/Stories'; import { Modal } from './Modal'; import { StoryDistributionListName } from './StoryDistributionListName'; +import { Theme } from '../util/theme'; export type PropsType = { + candidateConversations: Array; distributionLists: Array; + getPreferredBadge: PreferredBadgeSelectorType; + groupConversations: Array; + groupStories: Array; i18n: LocalizerType; me: ConversationType; onClose: () => unknown; - onSend: (listIds: Array) => unknown; + onDistributionListCreated: ( + name: string, + viewerUuids: Array + ) => unknown; + onSend: ( + listIds: Array, + conversationIds: Array + ) => unknown; signalConnections: Array; + tagGroupsAsNewGroupStory: (cids: Array) => unknown; }; +enum SendStoryPage { + SendStory = 'SendStory', + ChooseGroups = 'ChooseGroups', +} + +const Page = { + ...SendStoryPage, + ...StoriesSettingsPage, +}; + +type PageType = SendStoryPage | StoriesSettingsPage; + function getListViewers( list: StoryDistributionListDataType, i18n: LocalizerType, @@ -36,118 +70,420 @@ function getListViewers( } return memberCount === 1 - ? i18n('StoriesSettingsModal__viewers--singular', ['1']) + ? i18n('StoriesSettings__viewers--singular', ['1']) : i18n('StoriesSettings__viewers--plural', [String(memberCount)]); } export const SendStoryModal = ({ + candidateConversations, distributionLists, + getPreferredBadge, + groupConversations, + groupStories, i18n, me, onClose, + onDistributionListCreated, onSend, signalConnections, + tagGroupsAsNewGroupStory, }: PropsType): JSX.Element => { + const [page, setPage] = useState(Page.SendStory); + const [selectedListIds, setSelectedListIds] = useState>( new Set() ); - const selectedListNames = useMemo( + const [selectedGroupIds, setSelectedGroupIds] = useState>( + new Set() + ); + const selectedStoryNames = useMemo( () => distributionLists .filter(list => selectedListIds.has(list.id)) - .map(list => list.name), - [distributionLists, selectedListIds] + .map(list => list.name) + .concat( + groupStories + .filter(group => selectedGroupIds.has(group.id)) + .map(group => group.title) + ), + [distributionLists, groupStories, selectedGroupIds, selectedListIds] ); + const [searchTerm, setSearchTerm] = useState(''); + + const [filteredConversations, setFilteredConversations] = useState( + filterAndSortConversationsByRecent( + groupConversations, + searchTerm, + undefined + ) + ); + + const normalizedSearchTerm = searchTerm.trim(); + + useEffect(() => { + const timeout = setTimeout(() => { + setFilteredConversations( + filterAndSortConversationsByRecent( + groupConversations, + normalizedSearchTerm, + undefined + ) + ); + }, 200); + return () => { + clearTimeout(timeout); + }; + }, [groupConversations, normalizedSearchTerm, setFilteredConversations]); + + const [chosenGroupIds, setChosenGroupIds] = useState>( + new Set() + ); + + const chosenGroupNames = useMemo( + () => + filteredConversations + .filter(group => chosenGroupIds.has(group.id)) + .map(group => group.title), + [filteredConversations, chosenGroupIds] + ); + + const [selectedContacts, setSelectedContacts] = useState< + Array + >([]); + + let content: JSX.Element; + if (page === Page.ChooseViewers || page === Page.NameStory) { + content = ( + { + onDistributionListCreated(name, uuids); + setPage(Page.SendStory); + }} + onViewersUpdated={() => { + if (page === Page.ChooseViewers) { + setPage(Page.NameStory); + } else { + setPage(Page.SendStory); + } + }} + page={page} + selectedContacts={selectedContacts} + setSelectedContacts={setSelectedContacts} + /> + ); + } else if (page === Page.ChooseGroups) { + content = ( + <> + { + setSearchTerm(event.target.value); + }} + value={searchTerm} + /> + {filteredConversations.length ? ( + filteredConversations.map(group => ( + { + setChosenGroupIds(groupIds => { + if (value) { + groupIds.add(group.id); + } else { + groupIds.delete(group.id); + } + return new Set([...groupIds]); + }); + }} + > + {({ id, checkboxNode }) => ( + <> + + {checkboxNode} + + )} + + )) + ) : ( +
+ {i18n('noContactsFound')} +
+ )} + + ); + } else { + content = ( + <> +
+ {i18n('stories')} + setPage(Page.ChooseViewers), + }, + { + label: i18n('SendStoryModal__new-group--title'), + description: i18n('SendStoryModal__new-group--description'), + icon: 'SendStoryModal__icon--group', + onClick: () => setPage(Page.ChooseGroups), + }, + ]} + moduleClassName="SendStoryModal__new-story" + popperOptions={{ + placement: 'bottom', + strategy: 'absolute', + }} + theme={Theme.Dark} + > + {i18n('SendStoryModal__new')} + +
+ {distributionLists.map(list => ( + { + setSelectedListIds(listIds => { + if (value) { + listIds.add(list.id); + } else { + listIds.delete(list.id); + } + return new Set([...listIds]); + }); + }} + > + {({ id, checkboxNode }) => ( + <> + + {checkboxNode} + + )} + + ))} + {groupStories.map(group => ( + { + setSelectedGroupIds(groupIds => { + if (value) { + groupIds.add(group.id); + } else { + groupIds.delete(group.id); + } + return new Set([...groupIds]); + }); + }} + > + {({ id, checkboxNode }) => ( + <> + + {checkboxNode} + + )} + + ))} + + ); + } + + let modalTitle: string; + if (page === Page.ChooseGroups) { + modalTitle = i18n('SendStoryModal__choose-groups'); + } else if (page === Page.NameStory) { + modalTitle = i18n('StoriesSettings__name-story'); + } else if (page === Page.ChooseViewers) { + modalTitle = i18n('StoriesSettings__choose-viewers'); + } else { + modalTitle = i18n('SendStoryModal__title'); + } + + let selectedNames: string | undefined; + if (page === Page.ChooseGroups) { + selectedNames = chosenGroupNames.join(', '); + } else { + selectedNames = selectedStoryNames + .map(listName => getStoryDistributionListName(i18n, listName, listName)) + .join(', '); + } + + const hasBackButton = page !== Page.SendStory; + + let modalFooter: JSX.Element | undefined; + if (page === Page.SendStory || page === Page.ChooseGroups) { + modalFooter = ( + +
{selectedNames}
+ {page === Page.ChooseGroups && ( + -
- )} - {page === Page.NameStory && ( - - - - )} - {page === Page.HideStoryFrom && ( - - - - )} {confirmDeleteListId && ( ); }; + +type EditDistributionListPropsType = { + onDone: (name: string, viewerUuids: Array) => unknown; + onViewersUpdated: (viewerUuids: Array) => unknown; + page: Page; + selectedContacts: Array; + setSelectedContacts: (contacts: Array) => unknown; +} & Pick; + +export const EditDistributionList = ({ + candidateConversations, + getPreferredBadge, + i18n, + onDone, + onViewersUpdated, + page, + selectedContacts, + setSelectedContacts, +}: EditDistributionListPropsType): JSX.Element | null => { + const [storyName, setStoryName] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + + const normalizedSearchTerm = searchTerm.trim(); + + const [filteredConversations, setFilteredConversations] = useState( + filterConversations(candidateConversations, normalizedSearchTerm) + ); + + useEffect(() => { + const timeout = setTimeout(() => { + setFilteredConversations( + filterConversations(candidateConversations, normalizedSearchTerm) + ); + }, 200); + return () => { + clearTimeout(timeout); + }; + }, [candidateConversations, normalizedSearchTerm, setFilteredConversations]); + + const isEditingDistributionList = + page === Page.AddViewer || + page === Page.ChooseViewers || + page === Page.NameStory || + page === Page.HideStoryFrom; + + useEffect(() => { + if (!isEditingDistributionList) { + setSearchTerm(''); + } + }, [isEditingDistributionList]); + + const contactLookup = useMemo(() => { + const map = new Map(); + candidateConversations.forEach(contact => { + map.set(contact.id, contact); + }); + return map; + }, [candidateConversations]); + + const selectedConversationUuids: Set = useMemo( + () => + new Set(selectedContacts.map(contact => contact.uuid).filter(isNotNil)), + [selectedContacts] + ); + + const toggleSelectedConversation = useCallback( + (conversationId: string) => { + let removeContact = false; + const nextSelectedContacts = selectedContacts.filter(contact => { + if (contact.id === conversationId) { + removeContact = true; + return false; + } + return true; + }); + if (removeContact) { + setSelectedContacts(nextSelectedContacts); + return; + } + const selectedContact = contactLookup.get(conversationId); + if (selectedContact) { + setSelectedContacts([...nextSelectedContacts, selectedContact]); + } + }, + [contactLookup, selectedContacts, setSelectedContacts] + ); + + const isChoosingViewers = + page === Page.ChooseViewers || page === Page.AddViewer; + + if (page === Page.NameStory) { + return ( + <> +
+
+
+ + + +
+ {i18n('StoriesSettings__who-can-see')} +
+ + {selectedContacts.map(contact => ( +
+ + + + {contact.title} + + +
+ ))} + + + + + ); + } + + if ( + page === Page.AddViewer || + page === Page.ChooseViewers || + page === Page.HideStoryFrom + ) { + const rowCount = filteredConversations.length; + const getRow = (index: number): undefined | Row => { + const contact = filteredConversations[index]; + if (!contact || !contact.uuid) { + return undefined; + } + + const isSelected = selectedConversationUuids.has(UUID.cast(contact.uuid)); + + return { + type: RowType.ContactCheckbox, + contact, + isChecked: isSelected, + }; + }; + + return ( + <> + { + 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')} +
+ )} + {isChoosingViewers && ( + + + + )} + {page === Page.HideStoryFrom && ( + + + + )} + + ); + } + + return null; +}; diff --git a/ts/components/StoryCreator.stories.tsx b/ts/components/StoryCreator.stories.tsx index d4852a4d9..13922c599 100644 --- a/ts/components/StoryCreator.stories.tsx +++ b/ts/components/StoryCreator.stories.tsx @@ -8,7 +8,10 @@ import type { PropsType } from './StoryCreator'; import enMessages from '../../_locales/en/messages.json'; import { StoryCreator } from './StoryCreator'; import { fakeAttachment } from '../test-both/helpers/fakeAttachment'; -import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; +import { + getDefaultConversation, + getDefaultGroup, +} from '../test-both/helpers/getDefaultConversation'; import { getFakeDistributionLists } from '../test-both/helpers/getFakeDistributionLists'; import { setupI18n } from '../util/setupI18n'; @@ -18,19 +21,30 @@ export default { title: 'Components/StoryCreator', component: StoryCreator, argTypes: { + candidateConversations: { + defaultValue: Array.from(Array(100), getDefaultConversation), + }, debouncedMaybeGrabLinkPreview: { action: true }, distributionLists: { defaultValue: getFakeDistributionLists() }, - linkPreview: { - defaultValue: undefined, + getPreferredBadge: { action: true }, + groupConversations: { + defaultValue: Array.from(Array(7), getDefaultGroup), + }, + groupStories: { + defaultValue: Array.from(Array(4), getDefaultGroup), }, i18n: { defaultValue: i18n }, installedPacks: { defaultValue: [], }, + linkPreview: { + defaultValue: undefined, + }, me: { defaultValue: getDefaultConversation(), }, onClose: { action: true }, + onDistributionListCreated: { action: true }, onSend: { action: true }, processAttachment: { action: true }, recentStickers: { diff --git a/ts/components/StoryCreator.tsx b/ts/components/StoryCreator.tsx index 815e5726c..c21c3157e 100644 --- a/ts/components/StoryCreator.tsx +++ b/ts/components/StoryCreator.tsx @@ -12,6 +12,7 @@ import type { ConversationType } from '../state/ducks/conversations'; import type { LinkPreviewSourceType } from '../types/LinkPreview'; import type { LinkPreviewType } from '../types/message/LinkPreviews'; import type { LocalizerType } from '../types/Util'; +import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { Props as StickerButtonProps } from './stickers/StickerButton'; import type { StoryDistributionListDataType } from '../state/ducks/storyDistributionLists'; import type { UUIDStringType } from '../types/UUID'; @@ -24,16 +25,24 @@ import { MediaEditor } from './MediaEditor'; import { TextStoryCreator } from './TextStoryCreator'; export type PropsType = { + candidateConversations: Array; debouncedMaybeGrabLinkPreview: ( message: string, source: LinkPreviewSourceType ) => unknown; distributionLists: Array; file?: File; + getPreferredBadge: PreferredBadgeSelectorType; + groupConversations: Array; + groupStories: Array; i18n: LocalizerType; linkPreview?: LinkPreviewType; me: ConversationType; onClose: () => unknown; + onDistributionListCreated: ( + name: string, + viewerUuids: Array + ) => unknown; onSend: ( listIds: Array, conversationIds: Array, @@ -43,21 +52,28 @@ export type PropsType = { file: File ) => Promise; signalConnections: Array; + tagGroupsAsNewGroupStory: (cids: Array) => unknown; } & Pick; export const StoryCreator = ({ + candidateConversations, debouncedMaybeGrabLinkPreview, distributionLists, file, + getPreferredBadge, + groupConversations, + groupStories, i18n, installedPacks, linkPreview, me, onClose, + onDistributionListCreated, onSend, processAttachment, recentStickers, signalConnections, + tagGroupsAsNewGroupStory, }: PropsType): JSX.Element => { const [draftAttachment, setDraftAttachment] = useState< AttachmentType | undefined @@ -100,20 +116,27 @@ export const StoryCreator = ({ <> {draftAttachment && ( setDraftAttachment(undefined)} - onSend={listIds => { - onSend(listIds, [], draftAttachment); + onDistributionListCreated={onDistributionListCreated} + onSend={(listIds, groupIds) => { + onSend(listIds, groupIds, draftAttachment); setDraftAttachment(undefined); onClose(); }} signalConnections={signalConnections} + tagGroupsAsNewGroupStory={tagGroupsAsNewGroupStory} /> )} {attachmentUrl && ( = ariaLabel = i18n('selectContact', [title]); } actionNode = ( - { - if (onClick && !disabled && event.key === 'Enter') { - onClick(); - } - }} - type="checkbox" - /> +
+ { + if (onClick && !disabled && event.key === 'Enter') { + onClick(); + } + }} + type="checkbox" + /> +
); } diff --git a/ts/jobs/helpers/sendStory.ts b/ts/jobs/helpers/sendStory.ts index 4d77b1e0b..dc27d34c8 100644 --- a/ts/jobs/helpers/sendStory.ts +++ b/ts/jobs/helpers/sendStory.ts @@ -71,6 +71,14 @@ export async function sendStory( return; } + const messageConversation = message.getConversation(); + if (messageConversation !== conversation) { + log.error( + `stories.sendStory(${messageId}): Message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}` + ); + return; + } + const attachments = message.get('attachments') || []; const [attachment] = attachments; @@ -92,12 +100,17 @@ export async function sendStory( ); } + const groupV2 = isGroupV2(conversation.attributes) + ? conversation.getGroupV2Info() + : undefined; + // Some distribution lists need allowsReplies false, some need it set to true // we create this proto (for the sync message) and also to re-use some of the // attributes inside it. return messaging.getStoryMessage({ allowsReplies: true, fileAttachment, + groupV2, textAttachment, profileKey, }); @@ -153,7 +166,7 @@ export async function sendStory( const messageConversation = message.getConversation(); if (messageConversation !== conversation) { log.error( - `stories.sendStory: Message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}` + `stories.sendStory(${messageId}): Message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}` ); return; } @@ -301,7 +314,9 @@ export async function sendStory( urgent: false, }); - message.doNotSendSyncMessage = true; + // Do not send sync messages for distribution lists since that's sent + // in bulk at the end. + message.doNotSendSyncMessage = Boolean(distributionList); const messageSendPromise = message.send( handleMessageSend(innerPromise, { diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 9b948dfad..d2fbda631 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -343,6 +343,7 @@ export type ConversationAttributesType = { // to leave a group. left?: boolean; groupVersion?: number; + isGroupStorySendReady?: boolean; // GroupV1 only members?: Array; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index a64409726..c70bb5b4c 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -1807,6 +1807,7 @@ export class ConversationModel extends window.Backbone groupVersion, groupId: this.get('groupId'), groupLink: this.getGroupLink(), + isGroupStorySendReady: Boolean(this.get('isGroupStorySendReady')), hideStory: Boolean(this.get('hideStory')), inboxPosition, isArchived: this.get('isArchived'), diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 9db926513..f4b10bc4b 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -195,6 +195,7 @@ export type ConversationType = { groupVersion?: 1 | 2; groupId?: string; groupLink?: string; + isGroupStorySendReady?: boolean; messageRequestsEnabled?: boolean; acceptedMessageRequest: boolean; secretParams?: string; @@ -852,6 +853,7 @@ export const actions = { showConversation, startComposing, startSettingGroupMetadata, + tagGroupsAsNewGroupStory, toggleAdmin, toggleConversationInChooseMembers, toggleComposeEditingAvatar, @@ -1953,6 +1955,29 @@ function removeMemberFromGroup( }; } +function tagGroupsAsNewGroupStory( + conversationIds: Array +): ThunkAction { + return async dispatch => { + await Promise.all( + conversationIds.map(async conversationId => { + const conversation = window.ConversationController.get(conversationId); + if (!conversation) { + return; + } + + conversation.set({ isGroupStorySendReady: true }); + await window.Signal.Data.updateConversation(conversation.attributes); + }) + ); + + dispatch({ + type: 'NOOP', + payload: null, + }); + }; +} + function toggleAdmin( conversationId: string, contactId: string diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index caf2abf8a..6a2572e4b 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -517,6 +517,20 @@ export const getComposableGroups = createSelector( ) ); +export const getNonGroupStories = createSelector( + getComposableGroups, + (groups: Array): Array => + groups.filter(group => !group.isGroupStorySendReady) +); + +export const getGroupStories = createSelector( + getConversationLookup, + (conversationLookup: ConversationLookupType): Array => + Object.values(conversationLookup).filter( + conversation => conversation.isGroupStorySendReady + ) +); + const getNormalizedComposerConversationSearchTerm = createSelector( getComposerConversationSearchTerm, (searchTerm: string): string => searchTerm.trim() diff --git a/ts/state/selectors/storyDistributionLists.ts b/ts/state/selectors/storyDistributionLists.ts index da96424a6..30fa51e61 100644 --- a/ts/state/selectors/storyDistributionLists.ts +++ b/ts/state/selectors/storyDistributionLists.ts @@ -7,13 +7,14 @@ import type { StateType } from '../reducer'; import type { StoryDistributionListDataType } from '../ducks/storyDistributionLists'; import type { StoryDistributionListWithMembersDataType } from '../../types/Stories'; import { getConversationSelector } from './conversations'; +import { MY_STORIES_ID } from '../../types/Stories'; export const getDistributionLists = ( state: StateType ): Array => - state.storyDistributionLists.distributionLists.filter( - list => !list.deletedAtTimestamp - ); + state.storyDistributionLists.distributionLists + .filter(list => !list.deletedAtTimestamp) + .sort(list => (list.id === MY_STORIES_ID ? -1 : 1)); export const getDistributionListSelector = createSelector( getDistributionLists, diff --git a/ts/state/smart/StoryCreator.tsx b/ts/state/smart/StoryCreator.tsx index 02533e4d5..f9b278b5d 100644 --- a/ts/state/smart/StoryCreator.tsx +++ b/ts/state/smart/StoryCreator.tsx @@ -8,7 +8,13 @@ import type { LocalizerType } from '../../types/Util'; import type { StateType } from '../reducer'; import { LinkPreviewSourceType } from '../../types/LinkPreview'; import { StoryCreator } from '../../components/StoryCreator'; -import { getAllSignalConnections, getMe } from '../selectors/conversations'; +import { + getAllSignalConnections, + getCandidateContactsForNewGroup, + getGroupStories, + getMe, + getNonGroupStories, +} from '../selectors/conversations'; import { getDistributionLists } from '../selectors/storyDistributionLists'; import { getIntl } from '../selectors/user'; import { @@ -16,9 +22,12 @@ import { getRecentStickers, } from '../selectors/stickers'; import { getLinkPreview } from '../selectors/linkPreviews'; +import { getPreferredBadgeSelector } from '../selectors/badges'; import { processAttachment } from '../../util/processAttachment'; +import { useConversationsActions } from '../ducks/conversations'; import { useLinkPreviewActions } from '../ducks/linkPreviews'; import { useStoriesActions } from '../ducks/stories'; +import { useStoryDistributionListsActions } from '../ducks/storyDistributionLists'; export type PropsType = { file?: File; @@ -31,9 +40,15 @@ export function SmartStoryCreator({ }: PropsType): JSX.Element | null { const { debouncedMaybeGrabLinkPreview } = useLinkPreviewActions(); const { sendStoryMessage } = useStoriesActions(); + const { tagGroupsAsNewGroupStory } = useConversationsActions(); + const { createDistributionList } = useStoryDistributionListsActions(); - const i18n = useSelector(getIntl); + const candidateConversations = useSelector(getCandidateContactsForNewGroup); const distributionLists = useSelector(getDistributionLists); + const getPreferredBadge = useSelector(getPreferredBadgeSelector); + const groupConversations = useSelector(getNonGroupStories); + const groupStories = useSelector(getGroupStories); + const i18n = useSelector(getIntl); const installedPacks = useSelector(getInstalledStickerPacks); const linkPreviewForSource = useSelector(getLinkPreview); const me = useSelector(getMe); @@ -42,18 +57,24 @@ export function SmartStoryCreator({ return ( ); } diff --git a/ts/test-both/helpers/getDefaultConversation.ts b/ts/test-both/helpers/getDefaultConversation.ts index d1e8b0bf1..7f3c9c66d 100644 --- a/ts/test-both/helpers/getDefaultConversation.ts +++ b/ts/test-both/helpers/getDefaultConversation.ts @@ -42,6 +42,39 @@ export function getDefaultConversation( }; } +export function getDefaultGroup( + overrideProps: Partial = {} +): ConversationType { + const memberships = Array.from(Array(casual.integer(1, 20)), () => ({ + uuid: UUID.generate().toString(), + isAdmin: Boolean(casual.coin_flip), + })); + + return { + acceptedMessageRequest: true, + announcementsOnly: false, + avatarPath: getAvatarPath(), + badges: [], + color: getRandomColor(), + conversationColor: ConversationColors[0], + groupDescription: casual.sentence, + groupId: UUID.generate().toString(), + groupLink: casual.url, + groupVersion: 2, + id: UUID.generate().toString(), + isMe: false, + lastUpdated: casual.unix_time, + markedUnread: Boolean(overrideProps.markedUnread), + membersCount: memberships.length, + memberships, + sharedGroupNames: [], + title: casual.title, + type: 'group' as const, + uuid: UUID.generate().toString(), + ...overrideProps, + }; +} + export function getDefaultConversationWithUuid( overrideProps: Partial = {}, uuid: UUIDStringType = UUID.generate().toString()