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,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 (
<Checkbox
checked={selectedListIds.has(list.id)}
key={list.id}
label={getStoryDistributionListName(i18n, list.id, list.name)}
moduleClassName="SendStoryModal__distribution-list"
name="SendStoryModal__distribution-list"
onChange={(value: boolean) => {
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 }) => (
<ContextMenu
i18n={i18n}
menuOptions={
list.id === MY_STORIES_ID
? [
{
label: i18n('StoriesSettings__context-menu'),
icon: 'SendStoryModal__icon--delete',
onClick: () => 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}
>
<label
className="SendStoryModal__distribution-list__label"
htmlFor={id}
>
{list.id === MY_STORIES_ID ? (
<Avatar
acceptedMessageRequest={me.acceptedMessageRequest}
avatarPath={me.avatarPath}
badge={undefined}
color={me.color}
conversationType={me.type}
i18n={i18n}
isMe
sharedGroupNames={me.sharedGroupNames}
size={AvatarSize.THIRTY_SIX}
title={me.title}
/>
) : (
<span className="StoriesSettingsModal__list__avatar--custom" />
)}
<div className="SendStoryModal__distribution-list__info">
<div className="SendStoryModal__distribution-list__name">
<StoryDistributionListName
i18n={i18n}
id={list.id}
name={list.name}
/>
</div>
<div className="SendStoryModal__distribution-list__description">
{hasFirstStoryPostExperience &&
list.id === MY_STORIES_ID ? (
i18n('SendStoryModal__choose-who-can-view')
) : (
<>
<span className="SendStoryModal__rtl-span">
{list.id === MY_STORIES_ID
? i18n(getKeyForMyStoryType(list))
: i18n('SendStoryModal__custom-story')}
</span>
<span className="SendStoryModal__rtl-span">
&nbsp;&middot;&nbsp;
</span>
<span className="SendStoryModal__rtl-span">
{list.isBlockList && list.members.length > 0
? i18n('icu:SendStoryModal__excluded', {
count: list.members.length,
})
: getListViewers(list, i18n, signalConnections)}
</span>
</>
)}
</div>
</div>
</label>
{checkboxNode}
</ContextMenu>
)}
</Checkbox>
);
};
const renderGroup = (group: ConversationType) => {
return (
<Checkbox
checked={selectedGroupIds.has(group.id)}
key={group.id}
label={group.title}
moduleClassName="SendStoryModal__distribution-list"
name="SendStoryModal__distribution-list"
onChange={(value: boolean) => {
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 }) => (
<ContextMenu
i18n={i18n}
menuOptions={[
{
label: i18n('SendStoryModal__delete-story'),
icon: 'SendStoryModal__icon--delete',
onClick: () => setConfirmRemoveGroupId(group.id),
},
]}
moduleClassName="SendStoryModal__distribution-list-context"
onClick={noop}
popperOptions={{
placement: 'bottom',
strategy: 'absolute',
}}
theme={Theme.Dark}
>
<label
className="SendStoryModal__distribution-list__label"
htmlFor={id}
>
<Avatar
acceptedMessageRequest={group.acceptedMessageRequest}
avatarPath={group.avatarPath}
badge={undefined}
color={group.color}
conversationType={group.type}
i18n={i18n}
isMe={false}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_SIX}
title={group.title}
/>
<div className="SendStoryModal__distribution-list__info">
<div className="SendStoryModal__distribution-list__name">
{group.title}
</div>
<div className="SendStoryModal__distribution-list__description">
<span className="SendStoryModal__rtl-span">
{i18n('SendStoryModal__group-story')}
</span>
<span className="SendStoryModal__rtl-span">
&nbsp;&middot;&nbsp;
</span>
<span className="SendStoryModal__rtl-span">
{i18n('icu:ConversationHero--members', {
count: group.membersCount,
})}
</span>
</div>
</div>
</label>
{checkboxNode}
</ContextMenu>
)}
</Checkbox>
);
};
modal = handleClose => (
<ModalPage
modalName="SendStoryModal__title"
@ -663,222 +905,12 @@ export const SendStoryModal = ({
)}
</ContextMenu>
</div>
{distributionLists.map(list => (
<Checkbox
checked={selectedListIds.has(list.id)}
key={list.id}
label={getStoryDistributionListName(i18n, list.id, list.name)}
moduleClassName="SendStoryModal__distribution-list"
name="SendStoryModal__distribution-list"
onChange={(value: boolean) => {
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 }) => (
<ContextMenu
i18n={i18n}
menuOptions={
list.id === MY_STORIES_ID
? [
{
label: i18n('StoriesSettings__context-menu'),
icon: 'SendStoryModal__icon--delete',
onClick: () => 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}
>
<label
className="SendStoryModal__distribution-list__label"
htmlFor={id}
>
{list.id === MY_STORIES_ID ? (
<Avatar
acceptedMessageRequest={me.acceptedMessageRequest}
avatarPath={me.avatarPath}
badge={undefined}
color={me.color}
conversationType={me.type}
i18n={i18n}
isMe
sharedGroupNames={me.sharedGroupNames}
size={AvatarSize.THIRTY_SIX}
title={me.title}
/>
) : (
<span className="StoriesSettingsModal__list__avatar--custom" />
)}
<div className="SendStoryModal__distribution-list__info">
<div className="SendStoryModal__distribution-list__name">
<StoryDistributionListName
i18n={i18n}
id={list.id}
name={list.name}
/>
</div>
<div className="SendStoryModal__distribution-list__description">
{hasFirstStoryPostExperience &&
list.id === MY_STORIES_ID ? (
i18n('SendStoryModal__choose-who-can-view')
) : (
<>
<span className="SendStoryModal__rtl-span">
{list.id === MY_STORIES_ID
? i18n(getKeyForMyStoryType(list))
: i18n('SendStoryModal__custom-story')}
</span>
<span className="SendStoryModal__rtl-span">
&nbsp;&middot;&nbsp;
</span>
<span className="SendStoryModal__rtl-span">
{list.isBlockList && list.members.length > 0
? i18n('icu:SendStoryModal__excluded', {
count: list.members.length,
})
: getListViewers(list, i18n, signalConnections)}
</span>
</>
)}
</div>
</div>
</label>
{checkboxNode}
</ContextMenu>
)}
</Checkbox>
))}
{groupStories.map(group => (
<Checkbox
checked={selectedGroupIds.has(group.id)}
key={group.id}
label={group.title}
moduleClassName="SendStoryModal__distribution-list"
name="SendStoryModal__distribution-list"
onChange={(value: boolean) => {
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 }) => (
<ContextMenu
i18n={i18n}
menuOptions={[
{
label: i18n('SendStoryModal__delete-story'),
icon: 'SendStoryModal__icon--delete',
onClick: () => setConfirmRemoveGroupId(group.id),
},
]}
moduleClassName="SendStoryModal__distribution-list-context"
onClick={noop}
popperOptions={{
placement: 'bottom',
strategy: 'absolute',
}}
theme={Theme.Dark}
>
<label
className="SendStoryModal__distribution-list__label"
htmlFor={id}
>
<Avatar
acceptedMessageRequest={group.acceptedMessageRequest}
avatarPath={group.avatarPath}
badge={undefined}
color={group.color}
conversationType={group.type}
i18n={i18n}
isMe={false}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_SIX}
title={group.title}
/>
<div className="SendStoryModal__distribution-list__info">
<div className="SendStoryModal__distribution-list__name">
{group.title}
</div>
<div className="SendStoryModal__distribution-list__description">
<span className="SendStoryModal__rtl-span">
{i18n('SendStoryModal__group-story')}
</span>
<span className="SendStoryModal__rtl-span">
&nbsp;&middot;&nbsp;
</span>
<span className="SendStoryModal__rtl-span">
{i18n('icu:ConversationHero--members', {
count: group.membersCount,
})}
</span>
</div>
</div>
</label>
{checkboxNode}
</ContextMenu>
)}
</Checkbox>
))}
{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);
})
);