diff --git a/_locales/en/messages.json b/_locales/en/messages.json index efc8b0e4c7..c7ddb336f6 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5779,6 +5779,22 @@ "message": "Only admins can send stories to this group.", "description": "Alert body for groups that non-admins cannot send stories to" }, + "icu:SendStoryModal__my-stories-description-all": { + "messageformat": "All Signal connections · {viewersCount, plural, one {1 viewer} other {# viewers}}", + "description": "Shown as a subtitle under My Stories option in the send-story-to dialog when not exluding anyone" + }, + "icu:SendStoryModal__my-stories-description-excluding": { + "messageformat": "All Signal connections · {excludedCount, plural, one {1 excluded} other {# excluded}}", + "description": "Shown as a subtitle under My Stories option in the send-story-to dialog when excluding some" + }, + "icu:SendStoryModal__private-story-description": { + "messageformat": "Private story · {viewersCount, plural, one {1 viewer} other {# viewers}}", + "description": "Shown as a subtitle of each private story in the send-story-to dialog" + }, + "icu:SendStoryModal__group-story-description": { + "messageformat": "Group story · {membersCount, plural, one {1 member} other {# members}}", + "description": "Shown as a subtitle of each group story in the send-story-to dialog" + }, "Stories__settings-toggle--title": { "message": "Share & View Stories", "description": "Select box title for the stories on/off toggle" diff --git a/stylesheets/components/SendStoryModal.scss b/stylesheets/components/SendStoryModal.scss index 8b9c0c5383..b391898e2a 100644 --- a/stylesheets/components/SendStoryModal.scss +++ b/stylesheets/components/SendStoryModal.scss @@ -136,6 +136,7 @@ } &__distribution-list { + height: 52px; &__container { justify-content: space-between; padding: 8px 0; diff --git a/ts/components/SendStoryModal.tsx b/ts/components/SendStoryModal.tsx index 9dc1e63f94..2e22f1b6c0 100644 --- a/ts/components/SendStoryModal.tsx +++ b/ts/components/SendStoryModal.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import React, { useEffect, useMemo, useState } from 'react'; -import { noop } from 'lodash'; +import { noop, sortBy } from 'lodash'; import { SearchInput } from './SearchInput'; import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations'; @@ -62,6 +62,10 @@ export type PropsType = { ) => unknown; signalConnections: Array; toggleGroupsForStorySend: (cids: Array) => unknown; + mostRecentActiveStoryTimestampByGroupOrDistributionList: Record< + string, + number + >; } & Pick< StoriesSettingsModalPropsType, | 'onHideMyStoriesFrom' @@ -137,6 +141,7 @@ export const SendStoryModal = ({ setMyStoriesToAllSignalConnections, signalConnections, toggleGroupsForStorySend, + mostRecentActiveStoryTimestampByGroupOrDistributionList, toggleSignalConnectionsModal, }: PropsType): JSX.Element => { const [page, setPage] = useState(Page.SendStory); @@ -240,7 +245,7 @@ export const SendStoryModal = ({ [distributionLists] ); - const initialMyStories = useMemo( + const initialMyStories: StoryDistributionListWithMembersDataType = useMemo( () => ({ allowsReplies: true, id: MY_STORIES_ID, @@ -597,6 +602,243 @@ export const SendStoryModal = ({ url: objectUrl, }; + // my stories always first, the rest sorted by recency + const fullList = sortBy( + [...groupStories, ...distributionLists], + listOrGroup => { + if (listOrGroup.id === MY_STORIES_ID) { + return Number.NEGATIVE_INFINITY; + } + return ( + (mostRecentActiveStoryTimestampByGroupOrDistributionList[ + listOrGroup.id + ] ?? 0) * -1 + ); + } + ); + + const renderDistributionList = ( + list: StoryDistributionListWithMembersDataType + ): JSX.Element => { + return ( + { + if ( + list.id === MY_STORIES_ID && + hasFirstStoryPostExperience && + value + ) { + setPage(Page.SetMyStoriesPrivacy); + return; + } + + setSelectedListIds(listIds => { + if (value) { + listIds.add(list.id); + } else { + listIds.delete(list.id); + } + return new Set([...listIds]); + }); + if (value) { + onSelectedStoryList(getListMemberUuids(list, signalConnections)); + } + }} + > + {({ id, checkboxNode }) => ( + setListIdToEdit(list.id), + }, + ] + : [ + { + label: i18n('StoriesSettings__context-menu'), + icon: 'SendStoryModal__icon--settings', + onClick: () => setListIdToEdit(list.id), + }, + { + label: i18n('SendStoryModal__delete-story'), + icon: 'SendStoryModal__icon--delete', + onClick: () => setConfirmDeleteList(list), + }, + ] + } + moduleClassName="SendStoryModal__distribution-list-context" + onClick={noop} + popperOptions={{ + placement: 'bottom', + strategy: 'absolute', + }} + theme={Theme.Dark} + > + + {checkboxNode} + + )} + + ); + }; + + const renderGroup = (group: ConversationType) => { + return ( + { + if (!group.memberships) { + return; + } + + if (group.announcementsOnly && !group.areWeAdmin) { + setHasAnnouncementsOnlyAlert(true); + return; + } + + setSelectedGroupIds(groupIds => { + if (value) { + groupIds.add(group.id); + } else { + groupIds.delete(group.id); + } + return new Set([...groupIds]); + }); + if (value) { + onSelectedStoryList(group.memberships.map(({ uuid }) => uuid)); + } + }} + > + {({ id, checkboxNode }) => ( + setConfirmRemoveGroupId(group.id), + }, + ]} + moduleClassName="SendStoryModal__distribution-list-context" + onClick={noop} + popperOptions={{ + placement: 'bottom', + strategy: 'absolute', + }} + theme={Theme.Dark} + > + + {checkboxNode} + + )} + + ); + }; + modal = handleClose => ( - {distributionLists.map(list => ( - { - if ( - list.id === MY_STORIES_ID && - hasFirstStoryPostExperience && - value - ) { - setPage(Page.SetMyStoriesPrivacy); - return; - } - - setSelectedListIds(listIds => { - if (value) { - listIds.add(list.id); - } else { - listIds.delete(list.id); - } - return new Set([...listIds]); - }); - if (value) { - onSelectedStoryList( - getListMemberUuids(list, signalConnections) - ); - } - }} - > - {({ id, checkboxNode }) => ( - setListIdToEdit(list.id), - }, - ] - : [ - { - label: i18n('StoriesSettings__context-menu'), - icon: 'SendStoryModal__icon--settings', - onClick: () => setListIdToEdit(list.id), - }, - { - label: i18n('SendStoryModal__delete-story'), - icon: 'SendStoryModal__icon--delete', - onClick: () => setConfirmDeleteList(list), - }, - ] - } - moduleClassName="SendStoryModal__distribution-list-context" - onClick={noop} - popperOptions={{ - placement: 'bottom', - strategy: 'absolute', - }} - theme={Theme.Dark} - > - - {checkboxNode} - - )} - - ))} - {groupStories.map(group => ( - { - if (!group.memberships) { - return; - } - - if (group.announcementsOnly && !group.areWeAdmin) { - setHasAnnouncementsOnlyAlert(true); - return; - } - - setSelectedGroupIds(groupIds => { - if (value) { - groupIds.add(group.id); - } else { - groupIds.delete(group.id); - } - return new Set([...groupIds]); - }); - if (value) { - onSelectedStoryList(group.memberships.map(({ uuid }) => uuid)); - } - }} - > - {({ id, checkboxNode }) => ( - setConfirmRemoveGroupId(group.id), - }, - ]} - moduleClassName="SendStoryModal__distribution-list-context" - onClick={noop} - popperOptions={{ - placement: 'bottom', - strategy: 'absolute', - }} - theme={Theme.Dark} - > - - {checkboxNode} - - )} - - ))} + {fullList.map(listOrGroup => + // only group has a type field + 'type' in listOrGroup + ? renderGroup(listOrGroup) + : renderDistributionList(listOrGroup) + )} ); } diff --git a/ts/components/StoryCreator.tsx b/ts/components/StoryCreator.tsx index e0f67ff290..22d704e983 100644 --- a/ts/components/StoryCreator.tsx +++ b/ts/components/StoryCreator.tsx @@ -65,6 +65,7 @@ export type PropsType = { | 'setMyStoriesToAllSignalConnections' | 'signalConnections' | 'toggleGroupsForStorySend' + | 'mostRecentActiveStoryTimestampByGroupOrDistributionList' | 'toggleSignalConnectionsModal' >; @@ -98,6 +99,7 @@ export const StoryCreator = ({ setMyStoriesToAllSignalConnections, signalConnections, toggleGroupsForStorySend, + mostRecentActiveStoryTimestampByGroupOrDistributionList, toggleSignalConnectionsModal, }: PropsType): JSX.Element => { const [draftAttachment, setDraftAttachment] = useState< @@ -171,6 +173,9 @@ export const StoryCreator = ({ } signalConnections={signalConnections} toggleGroupsForStorySend={toggleGroupsForStorySend} + mostRecentActiveStoryTimestampByGroupOrDistributionList={ + mostRecentActiveStoryTimestampByGroupOrDistributionList + } toggleSignalConnectionsModal={toggleSignalConnectionsModal} /> )} diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 21c7bf0914..cd72e19136 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -5526,6 +5526,7 @@ export class ConversationModel extends window.Backbone return window.textsecure.storage.protocol.signAlternateIdentity(); } + /** @return only undefined if not a group */ getStorySendMode(): StorySendMode | undefined { if (!isGroup(this.attributes)) { return undefined; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 418cb4ea2f..fcdf74cfb3 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -916,7 +916,7 @@ async function getAvatarsAndUpdateConversation( conversation.attributes.avatars = nextAvatars.map(avatarData => omit(avatarData, ['buffer']) ); - await window.Signal.Data.updateConversation(conversation.attributes); + window.Signal.Data.updateConversation(conversation.attributes); return nextAvatars; } @@ -1264,7 +1264,7 @@ export function setVoiceNotePlaybackRate({ } else { conversationModel.attributes.voiceNotePlaybackRate = rate; } - await window.Signal.Data.updateConversation(conversationModel.attributes); + window.Signal.Data.updateConversation(conversationModel.attributes); } const conversation = conversationModel?.format(); @@ -1314,7 +1314,7 @@ function colorSelected({ delete conversation.attributes.customColorId; } - await window.Signal.Data.updateConversation(conversation.attributes); + window.Signal.Data.updateConversation(conversation.attributes); } dispatch({ @@ -2016,7 +2016,7 @@ function toggleGroupsForStorySend( conversation.set({ storySendMode: newStorySendMode, }); - await window.Signal.Data.updateConversation(conversation.attributes); + window.Signal.Data.updateConversation(conversation.attributes); conversation.captureChange('storySendMode'); }) ); diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index d1f10c6477..1b15718124 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -17,7 +17,7 @@ import type { MessagesByConversationType, PreJoinConversationType, } from '../ducks/conversations'; -import type { StoriesStateType } from '../ducks/stories'; +import type { StoriesStateType, StoryDataType } from '../ducks/stories'; import { ComposerStep, OneTimeModalState, @@ -61,6 +61,7 @@ import type { AccountSelectorType } from './accounts'; import { getAccountSelector } from './accounts'; import * as log from '../../logging/log'; import { TimelineMessageLoadingState } from '../../util/timelineUtil'; +import { reduce } from '../../util/iterables'; let placeholderContact: ConversationType; export const getPlaceholderContact = (): ConversationType => { @@ -545,6 +546,29 @@ export const getNonGroupStories = createSelector( } ); +export const selectMostRecentActiveStoryTimestampByGroupOrDistributionList = + createSelector( + (state: StateType): Array => state.stories.stories, + (stories: Array): Record => { + return reduce>( + stories, + (acc, story) => { + const distributionListOrConversationId = + story.storyDistributionListId ?? story.conversationId; + const cur = acc[distributionListOrConversationId]; + if (cur && story.timestamp < cur) { + return acc; + } + return { + ...acc, + [distributionListOrConversationId]: story.timestamp, + }; + }, + {} + ); + } + ); + export const getGroupStories = createSelector( getConversationLookup, getConversationIdsWithStories, diff --git a/ts/state/smart/StoryCreator.tsx b/ts/state/smart/StoryCreator.tsx index 7b449b611e..fc20a5cec2 100644 --- a/ts/state/smart/StoryCreator.tsx +++ b/ts/state/smart/StoryCreator.tsx @@ -14,6 +14,7 @@ import { getGroupStories, getMe, getNonGroupStories, + selectMostRecentActiveStoryTimestampByGroupOrDistributionList, } from '../selectors/conversations'; import { getDistributionListsWithMembers } from '../selectors/storyDistributionLists'; import { getIntl } from '../selectors/user'; @@ -70,6 +71,9 @@ export function SmartStoryCreator(): JSX.Element | null { const me = useSelector(getMe); const recentStickers = useSelector(getRecentStickers); const signalConnections = useSelector(getAllSignalConnections); + const mostRecentActiveStoryTimestampByGroupOrDistributionList = useSelector( + selectMostRecentActiveStoryTimestampByGroupOrDistributionList + ); const addStoryData = useSelector(getAddStoryData); const file = addStoryData?.type === 'Media' ? addStoryData.file : undefined; @@ -106,6 +110,9 @@ export function SmartStoryCreator(): JSX.Element | null { setMyStoriesToAllSignalConnections={setMyStoriesToAllSignalConnections} signalConnections={signalConnections} toggleGroupsForStorySend={toggleGroupsForStorySend} + mostRecentActiveStoryTimestampByGroupOrDistributionList={ + mostRecentActiveStoryTimestampByGroupOrDistributionList + } toggleSignalConnectionsModal={toggleSignalConnectionsModal} /> ); diff --git a/ts/util/sendStoryMessage.ts b/ts/util/sendStoryMessage.ts index 199a311cee..ccb0130d31 100644 --- a/ts/util/sendStoryMessage.ts +++ b/ts/util/sendStoryMessage.ts @@ -8,7 +8,7 @@ import type { UUIDStringType } from '../types/UUID'; import * as log from '../logging/log'; import dataInterface from '../sql/Client'; import { DAY, SECOND } from './durations'; -import { MY_STORIES_ID } from '../types/Stories'; +import { MY_STORIES_ID, StorySendMode } from '../types/Stories'; import { getStoriesBlocked } from './stories'; import { ReadStatus } from '../messages/MessageReadStatus'; import { SeenStatus } from '../MessageSeenStatus'; @@ -23,6 +23,7 @@ import { getSignalConnections } from './getSignalConnections'; import { incrementMessageCounter } from './incrementMessageCounter'; import { isGroupV2 } from './whatTypeOfConversation'; import { isNotNil } from './isNotNil'; +import { collect } from './iterables'; export async function sendStoryMessage( listIds: Array, @@ -180,8 +181,8 @@ export async function sendStoryMessage( MessageAttributesType >(); - await Promise.all( - conversationIds.map(async (conversationId, index) => { + const groupsToSendTo = Array.from( + collect(conversationIds, conversationId => { const group = window.ConversationController.get(conversationId); if (!group) { @@ -208,6 +209,27 @@ export async function sendStoryMessage( return; } + return group; + }) + ); + + // sending a story to a group marks it as one we want to always + // include on the send-story-to list + const groupsToUpdate = Array.from(groupsToSendTo).filter( + group => group.getStorySendMode() !== StorySendMode.Always + ); + for (const group of groupsToUpdate) { + group.set('storySendMode', StorySendMode.Always); + } + window.Signal.Data.updateConversations( + groupsToUpdate.map(group => group.attributes) + ); + for (const group of groupsToUpdate) { + group.captureChange('storySendMode'); + } + + await Promise.all( + groupsToSendTo.map(async (group, index) => { // We want all of these timestamps to be different from the My Story timestamp. const groupTimestamp = timestamp + index + 1; @@ -238,7 +260,7 @@ export async function sendStoryMessage( await window.Signal.Migrations.upgradeMessageSchema({ attachments, canReplyToStory: true, - conversationId, + conversationId: group.id, expireTimer: DAY / SECOND, expirationStartTimestamp: Date.now(), id: UUID.generate().toString(), @@ -255,7 +277,7 @@ export async function sendStoryMessage( type: 'story', }); - groupV2MessagesByConversationId.set(conversationId, messageAttributes); + groupV2MessagesByConversationId.set(group.id, messageAttributes); }) );