Show and sort active groups when sending a story

This commit is contained in:
Alvaro 2022-11-08 13:01:59 -07:00 committed by GitHub
parent 5078bc83f4
commit d0fb25f758
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 336 additions and 228 deletions

View file

@ -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"

View file

@ -136,6 +136,7 @@
}
&__distribution-list {
height: 52px;
&__container {
justify-content: space-between;
padding: 8px 0;

View file

@ -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<ConversationType>;
toggleGroupsForStorySend: (cids: Array<string>) => 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<PageType>(Page.SendStory);
@ -240,7 +245,7 @@ export const SendStoryModal = ({
[distributionLists]
);
const initialMyStories = useMemo(
const initialMyStories: StoryDistributionListWithMembersDataType = useMemo(
() => ({
allowsReplies: true,
id: MY_STORIES_ID,
@ -597,73 +602,25 @@ export const SendStoryModal = ({
url: objectUrl,
};
modal = handleClose => (
<ModalPage
modalName="SendStoryModal__title"
title={i18n('SendStoryModal__title')}
moduleClassName="SendStoryModal"
modalFooter={footer}
onClose={handleClose}
{...modalCommonProps}
>
<div
className="SendStoryModal__story-preview"
style={{ backgroundImage: getStoryBackground(attachment) }}
>
<StoryImage
i18n={i18n}
firstName={i18n('you')}
queueStoryDownload={noop}
storyId="story-id"
label="label"
moduleClassName="SendStoryModal__story"
attachment={attachment}
/>
</div>
<div className="SendStoryModal__top-bar">
{i18n('stories')}
<ContextMenu
aria-label={i18n('SendStoryModal__new')}
i18n={i18n}
menuOptions={[
{
label: i18n('SendStoryModal__new-custom--title'),
description: i18n('SendStoryModal__new-custom--description'),
icon: 'SendStoryModal__icon--custom',
onClick: () => 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}
>
{({ openMenu, onKeyDown, ref, menuNode }) => (
<div>
<Button
ref={ref}
className="SendStoryModal__new-story__button"
variant={ButtonVariant.Secondary}
size={ButtonSize.Small}
onClick={openMenu}
onKeyDown={onKeyDown}
>
{i18n('SendStoryModal__new')}
</Button>
{menuNode}
</div>
)}
</ContextMenu>
</div>
{distributionLists.map(list => (
// 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 (
<Checkbox
checked={selectedListIds.has(list.id)}
key={list.id}
@ -689,9 +646,7 @@ export const SendStoryModal = ({
return new Set([...listIds]);
});
if (value) {
onSelectedStoryList(
getListMemberUuids(list, signalConnections)
);
onSelectedStoryList(getListMemberUuids(list, signalConnections));
}
}}
>
@ -788,8 +743,11 @@ export const SendStoryModal = ({
</ContextMenu>
)}
</Checkbox>
))}
{groupStories.map(group => (
);
};
const renderGroup = (group: ConversationType) => {
return (
<Checkbox
checked={selectedGroupIds.has(group.id)}
key={group.id}
@ -878,7 +836,81 @@ export const SendStoryModal = ({
</ContextMenu>
)}
</Checkbox>
))}
);
};
modal = handleClose => (
<ModalPage
modalName="SendStoryModal__title"
title={i18n('SendStoryModal__title')}
moduleClassName="SendStoryModal"
modalFooter={footer}
onClose={handleClose}
{...modalCommonProps}
>
<div
className="SendStoryModal__story-preview"
style={{ backgroundImage: getStoryBackground(attachment) }}
>
<StoryImage
i18n={i18n}
firstName={i18n('you')}
queueStoryDownload={noop}
storyId="story-id"
label="label"
moduleClassName="SendStoryModal__story"
attachment={attachment}
/>
</div>
<div className="SendStoryModal__top-bar">
{i18n('stories')}
<ContextMenu
aria-label={i18n('SendStoryModal__new')}
i18n={i18n}
menuOptions={[
{
label: i18n('SendStoryModal__new-custom--title'),
description: i18n('SendStoryModal__new-custom--description'),
icon: 'SendStoryModal__icon--custom',
onClick: () => 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}
>
{({ openMenu, onKeyDown, ref, menuNode }) => (
<div>
<Button
ref={ref}
className="SendStoryModal__new-story__button"
variant={ButtonVariant.Secondary}
size={ButtonSize.Small}
onClick={openMenu}
onKeyDown={onKeyDown}
>
{i18n('SendStoryModal__new')}
</Button>
{menuNode}
</div>
)}
</ContextMenu>
</div>
{fullList.map(listOrGroup =>
// only group has a type field
'type' in listOrGroup
? renderGroup(listOrGroup)
: renderDistributionList(listOrGroup)
)}
</ModalPage>
);
}

View file

@ -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}
/>
)}

View file

@ -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;

View file

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

View file

@ -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<StoryDataType> => state.stories.stories,
(stories: Array<StoryDataType>): Record<string, number> => {
return reduce<StoryDataType, Record<string, number>>(
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,

View file

@ -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}
/>
);

View file

@ -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<string>,
@ -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);
})
);