Show and sort active groups when sending a story
This commit is contained in:
parent
5078bc83f4
commit
d0fb25f758
9 changed files with 336 additions and 228 deletions
|
@ -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"
|
||||
|
|
|
@ -136,6 +136,7 @@
|
|||
}
|
||||
|
||||
&__distribution-list {
|
||||
height: 52px;
|
||||
&__container {
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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');
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
})
|
||||
);
|
||||
|
||||
|
|
Loading…
Reference in a new issue