// Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { omit } from 'lodash'; import type { ThunkAction } from 'redux-thunk'; import type { ReadonlyDeep } from 'type-fest'; import type { StateType as RootStateType } from '../reducer'; import type { StoryDistributionWithMembersType } from '../../sql/Interface'; import type { StoryDistributionIdString } from '../../types/StoryDistributionId'; import type { ServiceIdString } from '../../types/ServiceId'; import * as log from '../../logging/log'; import { DataReader, DataWriter } from '../../sql/Client'; import { MY_STORY_ID } from '../../types/Stories'; import { generateStoryDistributionId } from '../../types/StoryDistributionId'; import { deleteStoryForEveryone } from '../../util/deleteStoryForEveryone'; import { replaceIndex } from '../../util/replaceIndex'; import { storageServiceUploadJob } from '../../services/storage'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import { useBoundActions } from '../../hooks/useBoundActions'; // State export type StoryDistributionListDataType = ReadonlyDeep<{ id: StoryDistributionIdString; deletedAtTimestamp?: number; name: string; allowsReplies: boolean; isBlockList: boolean; memberServiceIds: Array; }>; export type StoryDistributionListStateType = ReadonlyDeep<{ distributionLists: Array; }>; // Actions const ALLOW_REPLIES_CHANGED = 'storyDistributionLists/ALLOW_REPLIES_CHANGED'; const CREATE_LIST = 'storyDistributionLists/CREATE_LIST'; export const DELETE_LIST = 'storyDistributionLists/DELETE_LIST'; export const HIDE_MY_STORIES_FROM = 'storyDistributionLists/HIDE_MY_STORIES_FROM'; export const MODIFY_LIST = 'storyDistributionLists/MODIFY_LIST'; const RESET_MY_STORIES = 'storyDistributionLists/RESET_MY_STORIES'; export const VIEWERS_CHANGED = 'storyDistributionLists/VIEWERS_CHANGED'; type AllowRepliesChangedActionType = ReadonlyDeep<{ type: typeof ALLOW_REPLIES_CHANGED; payload: { listId: string; allowsReplies: boolean; }; }>; type CreateListActionType = ReadonlyDeep<{ type: typeof CREATE_LIST; payload: StoryDistributionListDataType; }>; type DeleteListActionType = ReadonlyDeep<{ type: typeof DELETE_LIST; payload: { listId: string; deletedAtTimestamp: number; }; }>; type HideMyStoriesFromActionType = ReadonlyDeep<{ type: typeof HIDE_MY_STORIES_FROM; payload: Array; }>; type ModifyDistributionListType = ReadonlyDeep< Omit & { membersToAdd: Array; membersToRemove: Array; } >; export type ModifyListActionType = ReadonlyDeep<{ type: typeof MODIFY_LIST; payload: ModifyDistributionListType; }>; type ResetMyStoriesActionType = ReadonlyDeep<{ type: typeof RESET_MY_STORIES; }>; type ViewersChangedActionType = ReadonlyDeep<{ type: typeof VIEWERS_CHANGED; payload: { listId: string; memberServiceIds: Array; }; }>; export type StoryDistributionListsActionType = ReadonlyDeep< | AllowRepliesChangedActionType | CreateListActionType | DeleteListActionType | HideMyStoriesFromActionType | ModifyListActionType | ResetMyStoriesActionType | ViewersChangedActionType >; // Action Creators function allowsRepliesChanged( listId: string, allowsReplies: boolean ): ThunkAction { return async dispatch => { const storyDistribution = await DataReader.getStoryDistributionWithMembers(listId); if (!storyDistribution) { log.warn( 'storyDistributionLists.allowsRepliesChanged: No story found for id', listId ); return; } if (storyDistribution.allowsReplies === allowsReplies) { log.warn( 'storyDistributionLists.allowsRepliesChanged: story already has the same value', { listId, allowsReplies } ); return; } await DataWriter.modifyStoryDistribution({ ...storyDistribution, allowsReplies, storageNeedsSync: true, }); storageServiceUploadJob(); log.info( 'storyDistributionLists.allowsRepliesChanged: allowsReplies has changed', listId ); dispatch({ type: ALLOW_REPLIES_CHANGED, payload: { listId, allowsReplies, }, }); }; } function createDistributionList( name: string, memberServiceIds: Array, storageServiceDistributionListRecord?: StoryDistributionWithMembersType, shouldSave = true ): ThunkAction< Promise, RootStateType, string, CreateListActionType > { return async dispatch => { const storyDistribution: StoryDistributionWithMembersType = { allowsReplies: true, id: generateStoryDistributionId(), isBlockList: false, members: memberServiceIds, name, senderKeyInfo: undefined, storageNeedsSync: true, ...(storageServiceDistributionListRecord || {}), }; if (shouldSave) { await DataWriter.createNewStoryDistribution(storyDistribution); } if (storyDistribution.storageNeedsSync) { storageServiceUploadJob(); } dispatch({ type: CREATE_LIST, payload: { allowsReplies: Boolean(storyDistribution.allowsReplies), deletedAtTimestamp: storyDistribution.deletedAtTimestamp, id: storyDistribution.id, isBlockList: Boolean(storyDistribution.isBlockList), memberServiceIds, name: storyDistribution.name, }, }); return storyDistribution.id; }; } function deleteDistributionList( listId: string ): ThunkAction { return async (dispatch, getState) => { const deletedAtTimestamp = Date.now(); const storyDistribution = await DataReader.getStoryDistributionWithMembers(listId); if (!storyDistribution) { log.warn('No story distribution found for id', listId); return; } await DataWriter.modifyStoryDistributionWithMembers( { ...storyDistribution, deletedAtTimestamp, name: '', storageNeedsSync: true, }, { toAdd: [], toRemove: storyDistribution.members, } ); const { stories } = getState().stories; const storiesToDelete = stories.filter( story => story.storyDistributionListId === listId ); await Promise.all( storiesToDelete.map(story => deleteStoryForEveryone(stories, story)) ); log.info( 'storyDistributionLists.deleteDistributionList: list deleted', listId ); storageServiceUploadJob(); dispatch({ type: DELETE_LIST, payload: { listId, deletedAtTimestamp, }, }); }; } function modifyDistributionList( distributionList: ModifyDistributionListType ): ModifyListActionType { return { type: MODIFY_LIST, payload: distributionList, }; } function hideMyStoriesFrom( memberServiceIds: Array ): ThunkAction { return async dispatch => { const myStories = await DataReader.getStoryDistributionWithMembers(MY_STORY_ID); if (!myStories) { log.error( 'storyDistributionLists.hideMyStoriesFrom: Could not find My Stories!' ); return; } const toAdd = new Set(memberServiceIds); await DataWriter.modifyStoryDistributionWithMembers( { ...myStories, isBlockList: true, storageNeedsSync: true, }, { toAdd: Array.from(toAdd), toRemove: myStories.members.filter(serviceId => !toAdd.has(serviceId)), } ); storageServiceUploadJob(); await window.storage.put('hasSetMyStoriesPrivacy', true); dispatch({ type: HIDE_MY_STORIES_FROM, payload: memberServiceIds, }); }; } function removeMembersFromDistributionList( listId: string, memberServiceIds: Array ): ThunkAction { return async dispatch => { if (!memberServiceIds.length) { log.warn( 'storyDistributionLists.removeMembersFromDistributionList cannot remove a member without serviceId', listId ); return; } const storyDistribution = await DataReader.getStoryDistributionWithMembers(listId); if (!storyDistribution) { log.warn( 'storyDistributionLists.removeMembersFromDistributionList: No story found for id', listId ); return; } let toAdd: Array = []; let toRemove: Array = memberServiceIds; let { isBlockList } = storyDistribution; // My Story is set to 'All Signal Connections' or is already an exclude list if ( listId === MY_STORY_ID && (storyDistribution.members.length === 0 || isBlockList) ) { isBlockList = true; toAdd = memberServiceIds; toRemove = []; // The user has now configured My Stories await window.storage.put('hasSetMyStoriesPrivacy', true); } await DataWriter.modifyStoryDistributionWithMembers( { ...storyDistribution, isBlockList, storageNeedsSync: true, }, { toAdd, toRemove, } ); log.info( 'storyDistributionLists.removeMembersFromDistributionList: removed', { listId, memberServiceIds, } ); storageServiceUploadJob(); dispatch({ type: MODIFY_LIST, payload: { ...omit(storyDistribution, ['members']), isBlockList, storageNeedsSync: true, membersToAdd: toAdd, membersToRemove: toRemove, }, }); }; } function setMyStoriesToAllSignalConnections(): ThunkAction< void, RootStateType, null, ResetMyStoriesActionType > { return async dispatch => { const myStories = await DataReader.getStoryDistributionWithMembers(MY_STORY_ID); if (!myStories) { log.error( 'storyDistributionLists.setMyStoriesToAllSignalConnections: Could not find My Stories!' ); return; } if (myStories.isBlockList || myStories.members.length > 0) { await DataWriter.modifyStoryDistributionWithMembers( { ...myStories, isBlockList: true, storageNeedsSync: true, }, { toAdd: [], toRemove: myStories.members, } ); storageServiceUploadJob(); } await window.storage.put('hasSetMyStoriesPrivacy', true); dispatch({ type: RESET_MY_STORIES, }); }; } function updateStoryViewers( listId: string, memberServiceIds: Array ): ThunkAction { return async dispatch => { const storyDistribution = await DataReader.getStoryDistributionWithMembers(listId); if (!storyDistribution) { log.warn( 'storyDistributionLists.updateStoryViewers: No story found for id', listId ); return; } const existingServiceIds = new Set( storyDistribution.members ); const toAdd: Array = []; memberServiceIds.forEach(serviceId => { if (!existingServiceIds.has(serviceId)) { toAdd.push(serviceId); } }); const updatedServiceIds = new Set(memberServiceIds); const toRemove: Array = []; storyDistribution.members.forEach(serviceId => { if (!updatedServiceIds.has(serviceId)) { toRemove.push(serviceId); } }); await DataWriter.modifyStoryDistributionWithMembers( { ...storyDistribution, isBlockList: false, storageNeedsSync: true, }, { toAdd, toRemove, } ); storageServiceUploadJob(); if (listId === MY_STORY_ID) { await window.storage.put('hasSetMyStoriesPrivacy', true); } dispatch({ type: VIEWERS_CHANGED, payload: { listId, memberServiceIds, }, }); }; } function removeMemberFromAllDistributionLists( member: ServiceIdString ): ThunkAction { return async dispatch => { const logId = `removeMemberFromAllDistributionLists(${member})`; const lists = await DataReader.getAllStoryDistributionsWithMembers(); const listsWithMember = lists.filter(({ members }) => members.includes(member) ); log.info( `${logId}: removing ${member} from ${listsWithMember.length} lists` ); for (const { id } of listsWithMember) { dispatch(removeMembersFromDistributionList(id, [member])); } }; } export const actions = { allowsRepliesChanged, createDistributionList, deleteDistributionList, hideMyStoriesFrom, modifyDistributionList, removeMembersFromDistributionList, removeMemberFromAllDistributionLists, setMyStoriesToAllSignalConnections, updateStoryViewers, }; export const useStoryDistributionListsActions = (): BoundActionCreatorsMapObject => useBoundActions(actions); // Reducer export function getEmptyState(): StoryDistributionListStateType { return { distributionLists: [], }; } function replaceDistributionListData( distributionLists: ReadonlyArray, listId: string, getNextDistributionListData: ( list: StoryDistributionListDataType ) => Partial ): Array | undefined { const listIndex = distributionLists.findIndex(list => list.id === listId); if (listIndex < 0) { return; } return replaceIndex(distributionLists, listIndex, { ...distributionLists[listIndex], ...getNextDistributionListData(distributionLists[listIndex]), }); } export function reducer( state: Readonly = getEmptyState(), action: Readonly ): StoryDistributionListStateType { if (action.type === MODIFY_LIST) { const { payload } = action; const { membersToAdd, membersToRemove, ...distributionListDetails } = payload; const listIndex = state.distributionLists.findIndex( list => list.id === distributionListDetails.id ); if (listIndex >= 0) { const existingDistributionList = state.distributionLists[listIndex]; const memberServiceIds = new Set( existingDistributionList.memberServiceIds ); membersToAdd.forEach(serviceId => memberServiceIds.add(serviceId)); membersToRemove.forEach(serviceId => memberServiceIds.delete(serviceId)); return { distributionLists: replaceIndex(state.distributionLists, listIndex, { ...existingDistributionList, ...distributionListDetails, memberServiceIds: Array.from(memberServiceIds), }), }; } return { distributionLists: [ ...state.distributionLists, { ...distributionListDetails, memberServiceIds: membersToAdd, }, ], }; } if (action.type === CREATE_LIST) { return { distributionLists: [...state.distributionLists, action.payload], }; } if (action.type === DELETE_LIST) { const distributionLists = replaceDistributionListData( state.distributionLists, action.payload.listId, () => ({ deletedAtTimestamp: action.payload.deletedAtTimestamp, memberServiceIds: [], name: '', }) ); return distributionLists ? { distributionLists } : state; } if (action.type === HIDE_MY_STORIES_FROM) { const distributionLists = replaceDistributionListData( state.distributionLists, MY_STORY_ID, () => ({ isBlockList: true, memberServiceIds: action.payload, }) ); return distributionLists ? { distributionLists } : state; } if (action.type === ALLOW_REPLIES_CHANGED) { const distributionLists = replaceDistributionListData( state.distributionLists, action.payload.listId, () => ({ allowsReplies: action.payload.allowsReplies, }) ); return distributionLists ? { distributionLists } : state; } if (action.type === VIEWERS_CHANGED) { const distributionLists = replaceDistributionListData( state.distributionLists, action.payload.listId, () => ({ isBlockList: false, memberServiceIds: Array.from(new Set(action.payload.memberServiceIds)), }) ); return distributionLists ? { distributionLists } : state; } if (action.type === RESET_MY_STORIES) { const distributionLists = replaceDistributionListData( state.distributionLists, MY_STORY_ID, () => ({ isBlockList: true, memberServiceIds: [], }) ); return distributionLists ? { distributionLists } : state; } return state; }