Moves parts of conversation view into redux

This commit is contained in:
Josh Perez 2022-12-05 17:56:23 -05:00 committed by GitHub
parent a49a6f2057
commit 9348940ecf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 693 additions and 461 deletions

View file

@ -1,28 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import { ToastDeleteForEveryoneFailed } from './ToastDeleteForEveryoneFailed';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
i18n,
onClose: action('onClose'),
};
export default {
title: 'Components/ToastDeleteForEveryoneFailed',
};
export const _ToastDeleteForEveryoneFailed = (): JSX.Element => (
<ToastDeleteForEveryoneFailed {...defaultProps} />
);
_ToastDeleteForEveryoneFailed.story = {
name: 'ToastDeleteForEveryoneFailed',
};

View file

@ -1,18 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Toast } from './Toast';
type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function ToastDeleteForEveryoneFailed({
i18n,
onClose,
}: PropsType): JSX.Element {
return <Toast onClose={onClose}>{i18n('deleteForEveryoneFailed')}</Toast>;
}

View file

@ -143,5 +143,9 @@ export function ToastManager({
);
}
if (toastType === ToastType.DeleteForEveryoneFailed) {
return <Toast onClose={hideToast}>{i18n('deleteForEveryoneFailed')}</Toast>;
}
throw missingCaseError(toastType);
}

View file

@ -38,8 +38,8 @@ const commonProps = {
i18n,
onShowConversationDetails: action('onShowConversationDetails'),
onSetDisappearingMessages: action('onSetDisappearingMessages'),
onDeleteMessages: action('onDeleteMessages'),
setDisappearingMessages: action('setDisappearingMessages'),
destroyMessages: action('destroyMessages'),
onSearchInConversation: action('onSearchInConversation'),
onSetMuteNotifications: action('onSetMuteNotifications'),
onOutgoingAudioCallInConversation: action(

View file

@ -28,6 +28,7 @@ import * as expirationTimer from '../../util/expirationTimer';
import { missingCaseError } from '../../util/missingCaseError';
import { isInSystemContacts } from '../../util/isInSystemContacts';
import { isConversationMuted } from '../../util/isConversationMuted';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { DurationInSeconds } from '../../util/durations';
import {
useStartCallShortcuts,
@ -80,8 +81,7 @@ export type PropsDataType = {
export type PropsActionsType = {
onSetMuteNotifications: (seconds: number) => void;
onSetDisappearingMessages: (seconds: DurationInSeconds) => void;
onDeleteMessages: () => void;
destroyMessages: (conversationId: string) => void;
onSearchInConversation: () => void;
onOutgoingAudioCallInConversation: () => void;
onOutgoingVideoCallInConversation: () => void;
@ -95,6 +95,10 @@ export type PropsActionsType = {
onArchive: () => void;
onMarkUnread: () => void;
onMoveToInbox: () => void;
setDisappearingMessages: (
conversationId: string,
seconds: DurationInSeconds
) => void;
viewUserStories: ViewUserStoriesActionCreatorType;
};
@ -112,6 +116,7 @@ enum ModalState {
}
type StateType = {
hasDeleteMessagesConfirmation: boolean;
isNarrow: boolean;
modalState: ModalState;
};
@ -130,7 +135,11 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
public constructor(props: PropsType) {
super(props);
this.state = { isNarrow: false, modalState: ModalState.NothingOpen };
this.state = {
hasDeleteMessagesConfirmation: false,
isNarrow: false,
modalState: ModalState.NothingOpen,
};
this.menuTriggerRef = React.createRef();
this.headerRef = React.createRef();
@ -329,6 +338,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
expireTimer,
groupVersion,
i18n,
id,
isArchived,
isMissingMandatoryProfileSharing,
isPinned,
@ -337,15 +347,14 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
markedUnread,
muteExpiresAt,
onArchive,
onDeleteMessages,
onMarkUnread,
onMoveToInbox,
onSetDisappearingMessages,
onSetMuteNotifications,
onSetPin,
onShowAllMedia,
onShowConversationDetails,
onShowGroupMembers,
setDisappearingMessages,
type,
} = this.props;
@ -425,7 +434,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
modalState: ModalState.CustomDisappearingTimeout,
});
} else {
onSetDisappearingMessages(seconds);
setDisappearingMessages(id, seconds);
}
};
@ -487,7 +496,11 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
) : (
<MenuItem onClick={onArchive}>{i18n('archiveConversation')}</MenuItem>
)}
<MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem>
<MenuItem
onClick={() => this.setState({ hasDeleteMessagesConfirmation: true })}
>
{i18n('deleteMessages')}
</MenuItem>
{isPinned ? (
<MenuItem onClick={() => onSetPin(false)}>
{i18n('unpinConversation')}
@ -501,6 +514,37 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
);
}
private renderConfirmationDialog(): ReactNode {
const { hasDeleteMessagesConfirmation } = this.state;
const { destroyMessages, i18n, id } = this.props;
if (!hasDeleteMessagesConfirmation) {
return;
}
return (
<ConfirmationDialog
dialogName="ConversationHeader.destroyMessages"
actions={[
{
action: () => {
this.setState({ hasDeleteMessagesConfirmation: false });
destroyMessages(id);
},
style: 'negative',
text: i18n('delete'),
},
]}
i18n={i18n}
onClose={() => {
this.setState({ hasDeleteMessagesConfirmation: false });
}}
>
{i18n('deleteConversationConfirmation')}
</ConfirmationDialog>
);
}
private renderHeader(): ReactNode {
const { conversationTitle, groupVersion, onShowConversationDetails, type } =
this.props;
@ -579,8 +623,8 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
isSignalConversation,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
onSetDisappearingMessages,
outgoingCallButtonStyle,
setDisappearingMessages,
showBackButton,
} = this.props;
const { isNarrow, modalState } = this.state;
@ -596,7 +640,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
initialValue={expireTimer}
onSubmit={value => {
this.setState({ modalState: ModalState.NothingOpen });
onSetDisappearingMessages(value);
setDisappearingMessages(id, value);
}}
onClose={() => this.setState({ modalState: ModalState.NothingOpen })}
/>
@ -608,6 +652,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
return (
<>
{modalNode}
{this.renderConfirmationDialog()}
<Measure
bounds
onResize={({ bounds }) => {

View file

@ -326,7 +326,7 @@ export class MessageDetail extends React.Component<Props> {
displayLimit={Number.MAX_SAFE_INTEGER}
displayTapToViewMessage={displayTapToViewMessage}
downloadAttachment={() =>
log.warn('MessageDetail: deleteMessageForEveryone called!')
log.warn('MessageDetail: downloadAttachment called!')
}
doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}
getPreferredBadge={getPreferredBadge}
@ -355,7 +355,7 @@ export class MessageDetail extends React.Component<Props> {
showExpiredOutgoingTapToViewToast
}
showMessageDetail={() => {
log.warn('MessageDetail: deleteMessageForEveryone called!');
log.warn('MessageDetail: showMessageDetail called!');
}}
showVisualAttachment={showVisualAttachment}
startConversation={startConversation}

View file

@ -26,6 +26,7 @@ import type {
} from './Message';
import { doesMessageBodyOverflow } from './MessageBodyReadMore';
import type { Props as ReactionPickerProps } from './ReactionPicker';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { useToggleReactionPicker } from '../../hooks/useKeyboardShortcuts';
export type PropsData = {
@ -237,6 +238,8 @@ export function TimelineMessage(props: Props): JSX.Element {
const handleReact = canReact ? () => toggleReactionPicker() : undefined;
const [hasDOEConfirmation, setHasDOEConfirmation] = useState(false);
const toggleReactionPickerKeyboard = useToggleReactionPicker(
handleReact || noop
);
@ -253,6 +256,22 @@ export function TimelineMessage(props: Props): JSX.Element {
return (
<>
{hasDOEConfirmation && canDeleteForEveryone && (
<ConfirmationDialog
actions={[
{
action: () => deleteMessageForEveryone(id),
style: 'negative',
text: i18n('delete'),
},
]}
dialogName="TimelineMessage/deleteMessageForEveryone"
i18n={i18n}
onClose={() => setHasDOEConfirmation(false)}
>
{i18n('deleteForEveryoneWarning')}
</ConfirmationDialog>
)}
<Message
{...props}
renderingContext="conversation/TimelineItem"
@ -319,7 +338,7 @@ export function TimelineMessage(props: Props): JSX.Element {
onForward={canForward ? () => showForwardMessageModal(id) : undefined}
onDeleteForMe={() => deleteMessage(id)}
onDeleteForEveryone={
canDeleteForEveryone ? () => deleteMessageForEveryone(id) : undefined
canDeleteForEveryone ? () => setHasDOEConfirmation(true) : undefined
}
onMoreInfo={() => showMessageDetail(id)}
/>

View file

@ -73,14 +73,12 @@ export type StateProps = {
i18n: LocalizerType;
isAdmin: boolean;
isGroup: boolean;
loadRecentMediaItems: (limit: number) => void;
groupsInCommon: Array<ConversationType>;
maxGroupSize: number;
maxRecommendedGroupSize: number;
memberships: Array<GroupV2Membership>;
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
setDisappearingMessages: (seconds: DurationInSeconds) => void;
showAllMedia: () => void;
showChatColorEditor: () => void;
showGroupLinkManagement: () => void;
@ -116,13 +114,15 @@ export type StateProps = {
type ActionProps = {
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
loadRecentMediaItems: (id: string, limit: number) => void;
replaceAvatar: ReplaceAvatarActionType;
saveAvatarToDisk: SaveAvatarToDiskActionType;
searchInConversation: (id: string) => unknown;
setDisappearingMessages: (id: string, seconds: DurationInSeconds) => void;
showContactModal: (contactId: string, conversationId?: string) => void;
showConversation: ShowConversationType;
toggleSafetyNumberModal: (conversationId: string) => unknown;
searchInConversation: (id: string) => unknown;
toggleAddUserToAnotherGroupModal: (contactId?: string) => void;
toggleSafetyNumberModal: (conversationId: string) => unknown;
};
export type Props = StateProps & ActionProps;
@ -412,7 +412,9 @@ export function ConversationDetails({
<DisappearingTimerSelect
i18n={i18n}
value={conversation.expireTimer || DurationInSeconds.ZERO}
onChange={setDisappearingMessages}
onChange={value =>
setDisappearingMessages(conversation.id, value)
}
/>
}
/>

View file

@ -15,7 +15,7 @@ import { MediaGridItem } from '../media-gallery/MediaGridItem';
export type Props = {
conversation: ConversationType;
i18n: LocalizerType;
loadRecentMediaItems: (limit: number) => void;
loadRecentMediaItems: (id: string, limit: number) => void;
showAllMedia: () => void;
showLightboxForMedia: (
selectedMediaItem: MediaItemType,
@ -39,8 +39,8 @@ export function ConversationDetailsMediaList({
const mediaItemsLength = mediaItems.length;
React.useEffect(() => {
loadRecentMediaItems(MEDIA_ITEM_LIMIT);
}, [loadRecentMediaItems, mediaItemsLength]);
loadRecentMediaItems(conversation.id, MEDIA_ITEM_LIMIT);
}, [conversation.id, loadRecentMediaItems, mediaItemsLength]);
if (mediaItemsLength === 0) {
return null;

View file

@ -13,14 +13,19 @@ import { PanelSection } from './PanelSection';
import { Select } from '../../Select';
import { useUniqueId } from '../../../hooks/useUniqueId';
export type PropsType = {
export type PropsDataType = {
conversation?: ConversationType;
i18n: LocalizerType;
setAccessControlAttributesSetting: (value: number) => void;
setAccessControlMembersSetting: (value: number) => void;
setAnnouncementsOnly: (value: boolean) => void;
};
type PropsActionType = {
setAccessControlAttributesSetting: (id: string, value: number) => void;
setAccessControlMembersSetting: (id: string, value: number) => void;
setAnnouncementsOnly: (id: string, value: boolean) => void;
};
export type PropsType = PropsDataType & PropsActionType;
export function GroupV2Permissions({
conversation,
i18n,
@ -37,14 +42,17 @@ export function GroupV2Permissions({
}
const updateAccessControlAttributes = (value: string) => {
setAccessControlAttributesSetting(Number(value));
setAccessControlAttributesSetting(conversation.id, Number(value));
};
const updateAccessControlMembers = (value: string) => {
setAccessControlMembersSetting(Number(value));
setAccessControlMembersSetting(conversation.id, Number(value));
};
const AccessControlEnum = Proto.AccessControl.AccessRequired;
const updateAnnouncementsOnly = (value: string) => {
setAnnouncementsOnly(Number(value) === AccessControlEnum.ADMINISTRATOR);
setAnnouncementsOnly(
conversation.id,
Number(value) === AccessControlEnum.ADMINISTRATOR
);
};
const accessControlOptions = getAccessControlOptions(i18n);
const announcementsOnlyValue = String(

View file

@ -48,7 +48,9 @@ const conversation: ConversationType = {
const OUR_UUID = UUID.generate().toString();
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
approvePendingMembership: action('approvePendingMembership'),
approvePendingMembershipFromGroupV2: action(
'approvePendingMembershipFromGroupV2'
),
conversation,
getPreferredBadge: () => undefined,
i18n,
@ -70,7 +72,9 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
},
})),
],
revokePendingMemberships: action('revokePendingMemberships'),
revokePendingMembershipsFromGroupV2: action(
'revokePendingMembershipsFromGroupV2'
),
theme: React.useContext(StorybookThemeContext),
...overrideProps,
});

View file

@ -17,18 +17,29 @@ import { ConversationDetailsIcon, IconType } from './ConversationDetailsIcon';
import { isAccessControlEnabled } from '../../../groups/util';
import { assertDev } from '../../../util/assert';
export type PropsType = {
export type PropsDataType = {
readonly conversation?: ConversationType;
readonly getPreferredBadge: PreferredBadgeSelectorType;
readonly i18n: LocalizerType;
readonly ourUuid: UUIDStringType;
readonly pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
readonly pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
readonly approvePendingMembership: (conversationId: string) => void;
readonly revokePendingMemberships: (conversationIds: Array<string>) => void;
readonly theme: ThemeType;
};
type PropsActionType = {
readonly approvePendingMembershipFromGroupV2: (
conversationId: string,
memberId: string
) => void;
readonly revokePendingMembershipsFromGroupV2: (
conversationId: string,
memberIds: Array<string>
) => void;
};
export type PropsType = PropsDataType & PropsActionType;
export type GroupV2PendingMembership = {
metadata: {
addedByUserId?: UUIDStringType;
@ -57,14 +68,14 @@ type StagedMembershipType = {
};
export function PendingInvites({
approvePendingMembership,
approvePendingMembershipFromGroupV2,
conversation,
getPreferredBadge,
i18n,
ourUuid,
pendingMemberships,
pendingApprovalMemberships,
revokePendingMemberships,
revokePendingMembershipsFromGroupV2,
theme,
}: PropsType): JSX.Element {
if (!conversation || !ourUuid) {
@ -148,13 +159,17 @@ export function PendingInvites({
{stagedMemberships && stagedMemberships.length && (
<MembershipActionConfirmation
approvePendingMembership={approvePendingMembership}
approvePendingMembershipFromGroupV2={
approvePendingMembershipFromGroupV2
}
conversation={conversation}
i18n={i18n}
members={conversation.sortedGroupMembers || []}
onClose={() => setStagedMemberships(null)}
ourUuid={ourUuid}
revokePendingMemberships={revokePendingMemberships}
revokePendingMembershipsFromGroupV2={
revokePendingMembershipsFromGroupV2
}
stagedMemberships={stagedMemberships}
/>
)}
@ -163,29 +178,36 @@ export function PendingInvites({
}
function MembershipActionConfirmation({
approvePendingMembership,
approvePendingMembershipFromGroupV2,
conversation,
i18n,
members,
onClose,
ourUuid,
revokePendingMemberships,
revokePendingMembershipsFromGroupV2,
stagedMemberships,
}: {
approvePendingMembership: (conversationId: string) => void;
approvePendingMembershipFromGroupV2: (
conversationId: string,
memberId: string
) => void;
conversation: ConversationType;
i18n: LocalizerType;
members: Array<ConversationType>;
onClose: () => void;
ourUuid: string;
revokePendingMemberships: (conversationIds: Array<string>) => void;
revokePendingMembershipsFromGroupV2: (
conversationId: string,
memberIds: Array<string>
) => void;
stagedMemberships: Array<StagedMembershipType>;
}) {
const revokeStagedMemberships = () => {
if (!stagedMemberships) {
return;
}
revokePendingMemberships(
revokePendingMembershipsFromGroupV2(
conversation.id,
stagedMemberships.map(({ membership }) => membership.member.id)
);
};
@ -194,7 +216,10 @@ function MembershipActionConfirmation({
if (!stagedMemberships) {
return;
}
approvePendingMembership(stagedMemberships[0].membership.member.id);
approvePendingMembershipFromGroupV2(
conversation.id,
stagedMemberships[0].membership.member.id
);
};
const membershipType = stagedMemberships[0].type;

View file

@ -150,6 +150,9 @@ import { getSendTarget } from '../util/getSendTarget';
import { getRecipients } from '../util/getRecipients';
import { validateConversation } from '../util/validateConversation';
import { isSignalConversation } from '../util/isSignalConversation';
import { isMemberRequestingToJoin } from '../util/isMemberRequestingToJoin';
import { removePendingMember } from '../util/removePendingMember';
import { isMemberPending } from '../util/isMemberPending';
/* eslint-disable more/no-then */
window.Whisper = window.Whisper || {};
@ -436,29 +439,11 @@ export class ConversationModel extends window.Backbone
}
private isMemberRequestingToJoin(uuid: UUID): boolean {
if (!isGroupV2(this.attributes)) {
return false;
}
const pendingAdminApprovalV2 = this.get('pendingAdminApprovalV2');
if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) {
return false;
}
return pendingAdminApprovalV2.some(item => item.uuid === uuid.toString());
return isMemberRequestingToJoin(this.attributes, uuid);
}
isMemberPending(uuid: UUID): boolean {
if (!isGroupV2(this.attributes)) {
return false;
}
const pendingMembersV2 = this.get('pendingMembersV2');
if (!pendingMembersV2 || !pendingMembersV2.length) {
return false;
}
return pendingMembersV2.some(item => item.uuid === uuid.toString());
return isMemberPending(this.attributes, uuid);
}
private isMemberBanned(uuid: UUID): boolean {
@ -569,28 +554,6 @@ export class ConversationModel extends window.Backbone
});
}
private async approvePendingApprovalRequest(
uuid: UUID
): Promise<Proto.GroupChange.Actions | undefined> {
const idLog = this.idForLogging();
// This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here
// in conflict/retry cases.
if (!this.isMemberRequestingToJoin(uuid)) {
log.warn(
`approvePendingApprovalRequest/${idLog}: ${uuid} is not requesting ` +
'to join the group. Returning early.'
);
return undefined;
}
return window.Signal.Groups.buildPromotePendingAdminApprovalMemberChange({
group: this.attributes,
uuid,
});
}
private async denyPendingApprovalRequest(
uuid: UUID
): Promise<Proto.GroupChange.Actions | undefined> {
@ -712,32 +675,7 @@ export class ConversationModel extends window.Backbone
private async removePendingMember(
uuids: ReadonlyArray<UUID>
): Promise<Proto.GroupChange.Actions | undefined> {
const idLog = this.idForLogging();
const pendingUuids = uuids
.map(uuid => {
// This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here
// in conflict/retry cases.
if (!this.isMemberPending(uuid)) {
log.warn(
`removePendingMember/${idLog}: ${uuid} is not a pending member of group. Returning early.`
);
return undefined;
}
return uuid;
})
.filter(isNotNil);
if (!uuids.length) {
return undefined;
}
return window.Signal.Groups.buildDeletePendingMemberChange({
group: this.attributes,
uuids: pendingUuids,
});
return removePendingMember(this.attributes, uuids);
}
private async removeMember(
@ -2631,85 +2569,6 @@ export class ConversationModel extends window.Backbone
});
}
async approvePendingMembershipFromGroupV2(
conversationId: string
): Promise<void> {
const logId = this.idForLogging();
const pendingMember = window.ConversationController.get(conversationId);
if (!pendingMember) {
throw new Error(
`approvePendingMembershipFromGroupV2/${logId}: No conversation found for conversation ${conversationId}`
);
}
const uuid = pendingMember.getCheckedUuid(
`approvePendingMembershipFromGroupV2/${logId}`
);
if (isGroupV2(this.attributes) && this.isMemberRequestingToJoin(uuid)) {
await this.modifyGroupV2({
name: 'approvePendingApprovalRequest',
usingCredentialsFrom: [pendingMember],
createGroupChange: () => this.approvePendingApprovalRequest(uuid),
});
}
}
async revokePendingMembershipsFromGroupV2(
conversationIds: Array<string>
): Promise<void> {
if (!isGroupV2(this.attributes)) {
return;
}
// Only pending memberships can be revoked for multiple members at once
if (conversationIds.length > 1) {
const uuids = conversationIds.map(id => {
const uuid = window.ConversationController.get(id)?.getUuid();
strictAssert(uuid, `UUID does not exist for ${id}`);
return uuid;
});
await this.modifyGroupV2({
name: 'removePendingMember',
usingCredentialsFrom: [],
createGroupChange: () => this.removePendingMember(uuids),
extraConversationsForSend: conversationIds,
});
return;
}
const [conversationId] = conversationIds;
const pendingMember = window.ConversationController.get(conversationId);
if (!pendingMember) {
const logId = this.idForLogging();
throw new Error(
`revokePendingMembershipsFromGroupV2/${logId}: No conversation found for conversation ${conversationId}`
);
}
const uuid = pendingMember.getCheckedUuid(
'revokePendingMembershipsFromGroupV2'
);
if (this.isMemberRequestingToJoin(uuid)) {
await this.modifyGroupV2({
name: 'denyPendingApprovalRequest',
usingCredentialsFrom: [],
createGroupChange: () => this.denyPendingApprovalRequest(uuid),
extraConversationsForSend: [conversationId],
});
} else if (this.isMemberPending(uuid)) {
await this.modifyGroupV2({
name: 'removePendingMember',
usingCredentialsFrom: [],
createGroupChange: () => this.removePendingMember([uuid]),
extraConversationsForSend: [conversationId],
});
}
}
async removeFromGroupV2(conversationId: string): Promise<void> {
if (!isGroupV2(this.attributes)) {
return;

View file

@ -12,6 +12,7 @@ import {
without,
} from 'lodash';
import type { AttachmentType } from '../../types/Attachment';
import type { StateType as RootStateType } from '../reducer';
import * as groups from '../../groups';
import * as log from '../../logging/log';
@ -90,12 +91,19 @@ import type { TimelineMessageLoadingState } from '../../util/timelineUtil';
import {
isDirectConversation,
isGroup,
isGroupV2,
} from '../../util/whatTypeOfConversation';
import { missingCaseError } from '../../util/missingCaseError';
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { isIncoming } from '../selectors/message';
import { sendDeleteForEveryoneMessage } from '../../util/sendDeleteForEveryoneMessage';
import type { ShowToastActionType } from './toast';
import { SHOW_TOAST, ToastType } from './toast';
import { isMemberRequestingToJoin } from '../../util/isMemberRequestingToJoin';
import { removePendingMember } from '../../util/removePendingMember';
import { denyPendingApprovalRequest } from '../../util/denyPendingApprovalRequest';
// State
@ -833,6 +841,7 @@ export type ConversationActionType =
export const actions = {
addMemberToGroup,
approvePendingMembershipFromGroupV2,
cancelConversationVerification,
changeHasGroupLink,
clearCancelledConversationVerification,
@ -854,9 +863,12 @@ export const actions = {
conversationUnloaded,
createGroup,
deleteAvatarFromDisk,
deleteMessageForEveryone,
destroyMessages,
discardMessages,
doubleCheckMissingQuoteReference,
generateNewGroupLink,
loadRecentMediaItems,
messageChanged,
messageDeleted,
messageExpanded,
@ -872,31 +884,35 @@ export const actions = {
resetAllChatColors,
reviewGroupMemberNameCollision,
reviewMessageRequestNameCollision,
revokePendingMembershipsFromGroupV2,
saveAvatarToDisk,
scrollToMessage,
selectMessage,
setAccessControlAddFromInviteLinkSetting,
setAccessControlAttributesSetting,
setAccessControlMembersSetting,
setAnnouncementsOnly,
setComposeGroupAvatar,
setComposeGroupExpireTimer,
setComposeGroupName,
setComposeSearchTerm,
setDisappearingMessages,
setIsFetchingUUID,
setIsNearBottom,
setMessageLoadingState,
setPreJoinConversation,
setRecentMediaItems,
setSelectedConversationHeaderTitle,
setSelectedConversationPanelDepth,
setVoiceNotePlaybackRate,
showArchivedConversations,
showChooseGroupMembers,
showInbox,
showConversation,
showInbox,
startComposing,
startSettingGroupMetadata,
toggleAdmin,
toggleConversationInChooseMembers,
toggleComposeEditingAvatar,
toggleConversationInChooseMembers,
toggleGroupsForStorySend,
toggleHideStories,
updateConversationModelSharedGroups,
@ -1003,6 +1019,125 @@ function changeHasGroupLink(
};
}
function setAnnouncementsOnly(
conversationId: string,
value: boolean
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('No conversation found');
}
await longRunningTaskWrapper({
name: 'updateAnnouncementsOnly',
idForLogging: conversation.idForLogging(),
task: async () => conversation.updateAnnouncementsOnly(value),
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function setAccessControlMembersSetting(
conversationId: string,
value: number
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('No conversation found');
}
await longRunningTaskWrapper({
name: 'updateAccessControlMembers',
idForLogging: conversation.idForLogging(),
task: async () => conversation.updateAccessControlMembers(value),
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function setAccessControlAttributesSetting(
conversationId: string,
value: number
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('No conversation found');
}
await longRunningTaskWrapper({
name: 'updateAccessControlAttributes',
idForLogging: conversation.idForLogging(),
task: async () => conversation.updateAccessControlAttributes(value),
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function setDisappearingMessages(
conversationId: string,
seconds: DurationInSeconds
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('No conversation found');
}
const valueToSet = seconds > 0 ? seconds : undefined;
await longRunningTaskWrapper({
name: 'updateExpirationTimer',
idForLogging: conversation.idForLogging(),
task: async () =>
conversation.updateExpirationTimer(valueToSet, {
reason: 'setDisappearingMessages',
}),
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function destroyMessages(
conversationId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('No conversation found');
}
await longRunningTaskWrapper({
name: 'destroymessages',
idForLogging: conversation.idForLogging(),
task: async () => {
conversation.trigger('unload', 'delete messages');
await conversation.destroyMessages();
conversation.updateLastMessage();
},
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function generateNewGroupLink(
conversationId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
@ -1845,15 +1980,248 @@ function setSelectedConversationPanelDepth(
payload: { panelDepth },
};
}
function setRecentMediaItems(
id: string,
recentMediaItems: Array<MediaItemType>
): SetRecentMediaItemsActionType {
return {
type: 'SET_RECENT_MEDIA_ITEMS',
payload: { id, recentMediaItems },
function deleteMessageForEveryone(
messageId: string
): ThunkAction<
void,
RootStateType,
unknown,
NoopActionType | ShowToastActionType
> {
return async dispatch => {
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(
`deleteMessageForEveryone: Message ${messageId} missing!`
);
}
const conversation = message.getConversation();
if (!conversation) {
throw new Error('deleteMessageForEveryone: no conversation');
}
try {
await sendDeleteForEveryoneMessage(conversation.attributes, {
id: message.id,
timestamp: message.get('sent_at'),
});
dispatch({
type: 'NOOP',
payload: null,
});
} catch (error) {
log.error(
'Error sending delete-for-everyone',
Errors.toLogFormat(error),
messageId
);
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.DeleteForEveryoneFailed,
},
});
}
};
}
function approvePendingMembershipFromGroupV2(
conversationId: string,
memberId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
`approvePendingMembershipFromGroupV2: No conversation found for conversation ${conversationId}`
);
}
const logId = conversation.idForLogging();
const pendingMember = window.ConversationController.get(memberId);
if (!pendingMember) {
throw new Error(
`approvePendingMembershipFromGroupV2/${logId}: No member found for conversation ${conversationId}`
);
}
const uuid = pendingMember.getCheckedUuid(
`approvePendingMembershipFromGroupV2/${logId}`
);
if (
isGroupV2(conversation.attributes) &&
isMemberRequestingToJoin(conversation.attributes, uuid)
) {
await window.Signal.Groups.modifyGroupV2({
conversation,
usingCredentialsFrom: [pendingMember],
createGroupChange: async () => {
// This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here
// in conflict/retry cases.
if (!isMemberRequestingToJoin(conversation.attributes, uuid)) {
log.warn(
`approvePendingMembershipFromGroupV2/${logId}: ${uuid} is not requesting ` +
'to join the group. Returning early.'
);
return undefined;
}
return window.Signal.Groups.buildPromotePendingAdminApprovalMemberChange(
{
group: conversation.attributes,
uuid,
}
);
},
name: 'approvePendingMembershipFromGroupV2',
});
}
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function revokePendingMembershipsFromGroupV2(
conversationId: string,
memberIds: Array<string>
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
`approvePendingMembershipFromGroupV2: No conversation found for conversation ${conversationId}`
);
}
if (!isGroupV2(conversation.attributes)) {
return;
}
// Only pending memberships can be revoked for multiple members at once
if (memberIds.length > 1) {
const uuids = memberIds.map(id => {
const uuid = window.ConversationController.get(id)?.getUuid();
strictAssert(uuid, `UUID does not exist for ${id}`);
return uuid;
});
await conversation.modifyGroupV2({
name: 'removePendingMember',
usingCredentialsFrom: [],
createGroupChange: () =>
removePendingMember(conversation.attributes, uuids),
extraConversationsForSend: memberIds,
});
return;
}
const [memberId] = memberIds;
const pendingMember = window.ConversationController.get(memberId);
if (!pendingMember) {
const logId = conversation.idForLogging();
throw new Error(
`revokePendingMembershipsFromGroupV2/${logId}: No conversation found for conversation ${memberId}`
);
}
const uuid = pendingMember.getCheckedUuid(
'revokePendingMembershipsFromGroupV2'
);
if (isMemberRequestingToJoin(conversation.attributes, uuid)) {
await conversation.modifyGroupV2({
name: 'denyPendingApprovalRequest',
usingCredentialsFrom: [],
createGroupChange: () =>
denyPendingApprovalRequest(conversation.attributes, uuid),
extraConversationsForSend: [memberId],
});
} else if (conversation.isMemberPending(uuid)) {
await conversation.modifyGroupV2({
name: 'removePendingMember',
usingCredentialsFrom: [],
createGroupChange: () =>
removePendingMember(conversation.attributes, [uuid]),
extraConversationsForSend: [memberId],
});
}
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function loadRecentMediaItems(
conversationId: string,
limit: number
): ThunkAction<void, RootStateType, unknown, SetRecentMediaItemsActionType> {
return async dispatch => {
const { getAbsoluteAttachmentPath } = window.Signal.Migrations;
const messages: Array<MessageAttributesType> =
await window.Signal.Data.getMessagesWithVisualMediaAttachments(
conversationId,
{
limit,
}
);
// Cache these messages in memory to ensure Lightbox can find them
messages.forEach(message => {
window.MessageController.register(message.id, message);
});
const recentMediaItems = messages
.filter(message => message.attachments !== undefined)
.reduce(
(acc, message) => [
...acc,
...(message.attachments || []).map(
(attachment: AttachmentType, index: number): MediaItemType => {
const { thumbnail } = attachment;
return {
objectURL: getAbsoluteAttachmentPath(attachment.path || ''),
thumbnailObjectUrl: thumbnail?.path
? getAbsoluteAttachmentPath(thumbnail.path)
: '',
contentType: attachment.contentType,
index,
attachment,
message: {
attachments: message.attachments || [],
conversationId:
window.ConversationController.get(message.sourceUuid)?.id ||
message.conversationId,
id: message.id,
received_at: message.received_at,
received_at_ms: Number(message.received_at_ms),
sent_at: message.sent_at,
},
};
}
),
],
[] as Array<MediaItemType>
);
dispatch({
type: 'SET_RECENT_MEDIA_ITEMS',
payload: { id: conversationId, recentMediaItems },
});
};
}
function clearInvitedUuidsForNewlyCreatedGroup(): ClearInvitedUuidsForNewlyCreatedGroupActionType {
return { type: 'CLEAR_INVITED_UUIDS_FOR_NEWLY_CREATED_GROUP' };
}

View file

@ -18,6 +18,7 @@ export enum ToastType {
FailedToDeleteUsername = 'FailedToDeleteUsername',
CopiedUsername = 'CopiedUsername',
CopiedUsernameLink = 'CopiedUsernameLink',
DeleteForEveryoneFailed = 'DeleteForEveryoneFailed',
}
// State
@ -32,13 +33,13 @@ export type ToastStateType = {
// Actions
const HIDE_TOAST = 'toast/HIDE_TOAST';
const SHOW_TOAST = 'toast/SHOW_TOAST';
export const SHOW_TOAST = 'toast/SHOW_TOAST';
type HideToastActionType = {
type: typeof HIDE_TOAST;
};
type ShowToastActionType = {
export type ShowToastActionType = {
type: typeof SHOW_TOAST;
payload: {
toastType: ToastType;

View file

@ -24,7 +24,6 @@ import {
getPreferredBadgeSelector,
} from '../selectors/badges';
import { assertDev } from '../../util/assert';
import type { DurationInSeconds } from '../../util/durations';
import { SignalService as Proto } from '../../protobuf';
import { getConversationColorAttributes } from '../../util/getConversationColorAttributes';
import type { SmartChooseGroupMembersModalPropsType } from './ChooseGroupMembersModal';
@ -39,8 +38,6 @@ import {
export type SmartConversationDetailsProps = {
addMembers: (conversationIds: ReadonlyArray<string>) => Promise<void>;
conversationId: string;
loadRecentMediaItems: (limit: number) => void;
setDisappearingMessages: (seconds: DurationInSeconds) => void;
showAllMedia: () => void;
showChatColorEditor: () => void;
showGroupLinkManagement: () => void;

View file

@ -25,20 +25,17 @@ import { mapDispatchToProps } from '../actions';
import { missingCaseError } from '../../util/missingCaseError';
import { strictAssert } from '../../util/assert';
import { isSignalConversation } from '../../util/isSignalConversation';
import type { DurationInSeconds } from '../../util/durations';
export type OwnProps = {
id: string;
onArchive: () => void;
onDeleteMessages: () => void;
onGoBack: () => void;
onMarkUnread: () => void;
onMoveToInbox: () => void;
onOutgoingAudioCallInConversation: () => void;
onOutgoingVideoCallInConversation: () => void;
onSearchInConversation: () => void;
onSetDisappearingMessages: (seconds: DurationInSeconds) => void;
onSetMuteNotifications: (seconds: number) => void;
onSetPin: (value: boolean) => void;
onShowAllMedia: () => void;

View file

@ -4,22 +4,20 @@
import { connect } from 'react-redux';
import type { StateType } from '../reducer';
import type { PropsType } from '../../components/conversation/conversation-details/GroupV2Permissions';
import type { PropsDataType } from '../../components/conversation/conversation-details/GroupV2Permissions';
import { mapDispatchToProps } from '../actions';
import { GroupV2Permissions } from '../../components/conversation/conversation-details/GroupV2Permissions';
import { getConversationSelector } from '../selectors/conversations';
import { getIntl } from '../selectors/user';
export type SmartGroupV2PermissionsProps = {
conversationId: string;
setAccessControlAttributesSetting: (value: number) => void;
setAccessControlMembersSetting: (value: number) => void;
setAnnouncementsOnly: (value: boolean) => void;
};
const mapStateToProps = (
state: StateType,
props: SmartGroupV2PermissionsProps
): PropsType => {
): PropsDataType => {
const conversation = getConversationSelector(state)(props.conversationId);
return {
@ -29,6 +27,6 @@ const mapStateToProps = (
};
};
const smart = connect(mapStateToProps);
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartGroupV2Permissions = smart(GroupV2Permissions);

View file

@ -3,7 +3,7 @@
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import type { PropsType } from '../../components/conversation/conversation-details/PendingInvites';
import type { PropsDataType } from '../../components/conversation/conversation-details/PendingInvites';
import { PendingInvites } from '../../components/conversation/conversation-details/PendingInvites';
import type { StateType } from '../reducer';
@ -20,14 +20,12 @@ import type { UUIDStringType } from '../../types/UUID';
export type SmartPendingInvitesProps = {
conversationId: string;
ourUuid: UUIDStringType;
readonly approvePendingMembership: (conversationid: string) => void;
readonly revokePendingMemberships: (membershipIds: Array<string>) => void;
};
const mapStateToProps = (
state: StateType,
props: SmartPendingInvitesProps
): PropsType => {
): PropsDataType => {
const conversationSelector = getConversationByIdSelector(state);
const conversationByUuidSelector = getConversationByUuidSelector(state);

View file

@ -66,7 +66,6 @@ export type TimelinePropsType = ExternalProps &
| 'contactSupport'
| 'blockGroupLinkRequests'
| 'deleteMessage'
| 'deleteMessageForEveryone'
| 'displayTapToViewMessage'
| 'downloadAttachment'
| 'downloadNewVersion'

View file

@ -0,0 +1,36 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationAttributesType } from '../model-types.d';
import type { SignalService as Proto } from '../protobuf';
import type { UUID } from '../types/UUID';
import * as log from '../logging/log';
import { UUIDKind } from '../types/UUID';
import { getConversationIdForLogging } from './idForLogging';
import { isMemberRequestingToJoin } from './isMemberRequestingToJoin';
export async function denyPendingApprovalRequest(
conversationAttributes: ConversationAttributesType,
uuid: UUID
): Promise<Proto.GroupChange.Actions | undefined> {
const idLog = getConversationIdForLogging(conversationAttributes);
// This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here
// in conflict/retry cases.
if (!isMemberRequestingToJoin(conversationAttributes, uuid)) {
log.warn(
`denyPendingApprovalRequest/${idLog}: ${uuid} is not requesting ` +
'to join the group. Returning early.'
);
return undefined;
}
const ourUuid = window.textsecure.storage.user.getCheckedUuid(UUIDKind.ACI);
return window.Signal.Groups.buildDeletePendingAdminApprovalMemberChange({
group: conversationAttributes,
ourUuid,
uuid,
});
}

View file

@ -0,0 +1,25 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { UUID } from '../types/UUID';
import type { ConversationAttributesType } from '../model-types.d';
import { isGroupV2 } from './whatTypeOfConversation';
export function isMemberPending(
conversationAttrs: Pick<
ConversationAttributesType,
'groupId' | 'groupVersion' | 'pendingMembersV2'
>,
uuid: UUID
): boolean {
if (!isGroupV2(conversationAttrs)) {
return false;
}
const { pendingMembersV2 } = conversationAttrs;
if (!pendingMembersV2 || !pendingMembersV2.length) {
return false;
}
return pendingMembersV2.some(item => item.uuid === uuid.toString());
}

View file

@ -0,0 +1,25 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { UUID } from '../types/UUID';
import type { ConversationAttributesType } from '../model-types.d';
import { isGroupV2 } from './whatTypeOfConversation';
export function isMemberRequestingToJoin(
conversationAttrs: Pick<
ConversationAttributesType,
'groupId' | 'groupVersion' | 'pendingAdminApprovalV2'
>,
uuid: UUID
): boolean {
if (!isGroupV2(conversationAttrs)) {
return false;
}
const { pendingAdminApprovalV2 } = conversationAttrs;
if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) {
return false;
}
return pendingAdminApprovalV2.some(item => item.uuid === uuid.toString());
}

View file

@ -0,0 +1,42 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationAttributesType } from '../model-types.d';
import type { SignalService as Proto } from '../protobuf';
import type { UUID } from '../types/UUID';
import * as log from '../logging/log';
import { getConversationIdForLogging } from './idForLogging';
import { isMemberPending } from './isMemberPending';
import { isNotNil } from './isNotNil';
export async function removePendingMember(
conversationAttributes: ConversationAttributesType,
uuids: ReadonlyArray<UUID>
): Promise<Proto.GroupChange.Actions | undefined> {
const idLog = getConversationIdForLogging(conversationAttributes);
const pendingUuids = uuids
.map(uuid => {
// This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here
// in conflict/retry cases.
if (!isMemberPending(conversationAttributes, uuid)) {
log.warn(
`removePendingMember/${idLog}: ${uuid} is not a pending member of group. Returning early.`
);
return undefined;
}
return uuid;
})
.filter(isNotNil);
if (!uuids.length) {
return undefined;
}
return window.Signal.Groups.buildDeletePendingMemberChange({
group: conversationAttributes,
uuids: pendingUuids,
});
}

View file

@ -27,7 +27,6 @@ import type {
ToastInternalError,
ToastPropsType as ToastInternalErrorPropsType,
} from '../components/ToastInternalError';
import type { ToastDeleteForEveryoneFailed } from '../components/ToastDeleteForEveryoneFailed';
import type { ToastExpired } from '../components/ToastExpired';
import type {
ToastFileSaved,
@ -79,7 +78,6 @@ export function showToast(
Toast: typeof ToastInternalError,
props: ToastInternalErrorPropsType
): void;
export function showToast(Toast: typeof ToastDeleteForEveryoneFailed): void;
export function showToast(Toast: typeof ToastExpired): void;
export function showToast(
Toast: typeof ToastFileSaved,

View file

@ -0,0 +1,19 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { UUIDStringType } from '../types/UUID';
import { strictAssert } from './assert';
export function startConversation(e164: string, uuid: UUIDStringType): void {
const conversation = window.ConversationController.lookupOrCreate({
e164,
uuid,
reason: 'util/startConversation',
});
strictAssert(
conversation,
`startConversation failed given ${e164}/${uuid} combination`
);
window.Whisper.events.trigger('showConversation', conversation.id);
}

View file

@ -36,7 +36,6 @@ import {
isGroupV1,
} from '../util/whatTypeOfConversation';
import { findAndFormatContact } from '../util/findAndFormatContact';
import type { DurationInSeconds } from '../util/durations';
import { getPreferredBadgeSelector } from '../state/selectors/badges';
import {
canReply,
@ -67,7 +66,6 @@ import { ToastConversationArchived } from '../components/ToastConversationArchiv
import { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread';
import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived';
import { ToastDangerousFileType } from '../components/ToastDangerousFileType';
import { ToastDeleteForEveryoneFailed } from '../components/ToastDeleteForEveryoneFailed';
import { ToastExpired } from '../components/ToastExpired';
import { ToastFileSize } from '../components/ToastFileSize';
import { ToastInvalidConversation } from '../components/ToastInvalidConversation';
@ -107,13 +105,13 @@ import {
import { LinkPreviewSourceType } from '../types/LinkPreview';
import { closeLightbox, showLightbox } from '../util/showLightbox';
import { saveAttachment } from '../util/saveAttachment';
import { sendDeleteForEveryoneMessage } from '../util/sendDeleteForEveryoneMessage';
import { SECOND } from '../util/durations';
import { blockSendUntilConversationsAreVerified } from '../util/blockSendUntilConversationsAreVerified';
import { SafetyNumberChangeSource } from '../components/SafetyNumberChangeDialog';
import { getOwn } from '../util/getOwn';
import { CallMode } from '../types/Calling';
import { isAnybodyElseInGroupCall } from '../state/ducks/calling';
import { startConversation } from '../util/startConversation';
type AttachmentOptions = {
messageId: string;
@ -136,7 +134,6 @@ const { getMessagesBySentAt } = window.Signal.Data;
type MessageActionsType = {
deleteMessage: (messageId: string) => unknown;
deleteMessageForEveryone: (messageId: string) => unknown;
displayTapToViewMessage: (messageId: string) => unknown;
downloadAttachment: (options: {
attachment: AttachmentType;
@ -337,9 +334,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
const conversationHeaderProps = {
id: this.model.id,
onSetDisappearingMessages: (seconds: DurationInSeconds) =>
this.setDisappearingMessages(seconds),
onDeleteMessages: () => this.destroyMessages(),
onSearchInConversation: () => {
const { searchInConversation } = window.reduxActions.search;
searchInConversation(this.model.id);
@ -737,9 +731,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
const deleteMessage = (messageId: string) => {
this.deleteMessage(messageId);
};
const deleteMessageForEveryone = (messageId: string) => {
this.deleteMessageForEveryone(messageId);
};
const showMessageDetail = (messageId: string) => {
this.showMessageDetail(messageId);
};
@ -826,11 +817,9 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
};
const showForwardMessageModal = this.showForwardMessageModal.bind(this);
const startConversation = this.startConversation.bind(this);
return {
deleteMessage,
deleteMessageForEveryone,
displayTapToViewMessage,
downloadAttachment,
downloadNewVersion,
@ -1661,38 +1650,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
});
}
deleteMessageForEveryone(messageId: string): void {
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(
`deleteMessageForEveryone: Message ${messageId} missing!`
);
}
window.showConfirmationDialog({
dialogName: 'deleteMessageForEveryone',
confirmStyle: 'negative',
message: window.i18n('deleteForEveryoneWarning'),
okText: window.i18n('delete'),
resolve: async () => {
try {
await sendDeleteForEveryoneMessage(this.model.attributes, {
id: message.id,
timestamp: message.get('sent_at'),
});
} catch (error) {
log.error(
'Error sending delete-for-everyone',
Errors.toLogFormat(error),
messageId
);
showToast(ToastDeleteForEveryoneFailed);
}
this.resetPanel();
},
});
}
showStickerPackPreview(packId: string, packKey: string): void {
Stickers.downloadEphemeralPack(packId, packKey);
@ -1873,11 +1830,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
window.reduxStore,
{
conversationId: this.model.id,
setAccessControlAttributesSetting:
this.setAccessControlAttributesSetting.bind(this),
setAccessControlMembersSetting:
this.setAccessControlMembersSetting.bind(this),
setAnnouncementsOnly: this.setAnnouncementsOnly.bind(this),
}
),
});
@ -1893,12 +1845,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
JSX: window.Signal.State.Roots.createPendingInvites(window.reduxStore, {
conversationId: this.model.id,
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
approvePendingMembership: (conversationId: string) => {
this.model.approvePendingMembershipFromGroupV2(conversationId);
},
revokePendingMemberships: conversationIds => {
this.model.revokePendingMembershipsFromGroupV2(conversationIds);
},
}),
});
const headerTitle = window.i18n(
@ -1971,8 +1917,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
const props = {
addMembers: this.model.addMembersV2.bind(this.model),
conversationId: this.model.get('id'),
loadRecentMediaItems: this.loadRecentMediaItems.bind(this),
setDisappearingMessages: this.setDisappearingMessages.bind(this),
showAllMedia: this.showAllMedia.bind(this),
showContactModal: this.showContactModal.bind(this),
showChatColorEditor: this.showChatColorEditor.bind(this),
@ -2092,10 +2036,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
hasSignalAccount={Boolean(signalAccount)}
onSendMessage={() => {
if (signalAccount) {
this.startConversation(
signalAccount.phoneNumber,
signalAccount.uuid
);
startConversation(signalAccount.phoneNumber, signalAccount.uuid);
}
}}
/>
@ -2108,20 +2049,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.addPanel({ view });
}
startConversation(e164: string, uuid: UUIDStringType): void {
const conversation = window.ConversationController.lookupOrCreate({
e164,
uuid,
reason: 'conversation_view.startConversation',
});
strictAssert(
conversation,
`startConversation failed given ${e164}/${uuid} combination`
);
this.openConversation(conversation.id);
}
async openConversation(
conversationId: string,
messageId?: string
@ -2201,124 +2128,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
);
}
async loadRecentMediaItems(limit: number): Promise<void> {
const { model }: { model: ConversationModel } = this;
const messages: Array<MessageAttributesType> =
await window.Signal.Data.getMessagesWithVisualMediaAttachments(model.id, {
limit,
});
// Cache these messages in memory to ensure Lightbox can find them
messages.forEach(message => {
window.MessageController.register(message.id, message);
});
const loadedRecentMediaItems = messages
.filter(message => message.attachments !== undefined)
.reduce(
(acc, message) => [
...acc,
...(message.attachments || []).map(
(attachment: AttachmentType, index: number): MediaItemType => {
const { thumbnail } = attachment;
return {
objectURL: getAbsoluteAttachmentPath(attachment.path || ''),
thumbnailObjectUrl: thumbnail?.path
? getAbsoluteAttachmentPath(thumbnail.path)
: '',
contentType: attachment.contentType,
index,
attachment,
message: {
attachments: message.attachments || [],
conversationId:
window.ConversationController.get(message.sourceUuid)?.id ||
message.conversationId,
id: message.id,
received_at: message.received_at,
received_at_ms: Number(message.received_at_ms),
sent_at: message.sent_at,
},
};
}
),
],
[] as Array<MediaItemType>
);
window.reduxActions.conversations.setRecentMediaItems(
model.id,
loadedRecentMediaItems
);
}
async setDisappearingMessages(seconds: DurationInSeconds): Promise<void> {
const { model }: { model: ConversationModel } = this;
const valueToSet = seconds > 0 ? seconds : undefined;
await this.longRunningTaskWrapper({
name: 'updateExpirationTimer',
task: async () =>
model.updateExpirationTimer(valueToSet, {
reason: 'setDisappearingMessages',
}),
});
}
async setAccessControlAttributesSetting(value: number): Promise<void> {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
name: 'updateAccessControlAttributes',
task: async () => model.updateAccessControlAttributes(value),
});
}
async setAccessControlMembersSetting(value: number): Promise<void> {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
name: 'updateAccessControlMembers',
task: async () => model.updateAccessControlMembers(value),
});
}
async setAnnouncementsOnly(value: boolean): Promise<void> {
const { model }: { model: ConversationModel } = this;
await this.longRunningTaskWrapper({
name: 'updateAnnouncementsOnly',
task: async () => model.updateAnnouncementsOnly(value),
});
}
async destroyMessages(): Promise<void> {
const { model }: { model: ConversationModel } = this;
window.showConfirmationDialog({
dialogName: 'destroyMessages',
confirmStyle: 'negative',
message: window.i18n('deleteConversationConfirmation'),
okText: window.i18n('delete'),
resolve: () => {
this.longRunningTaskWrapper({
name: 'destroymessages',
task: async () => {
model.trigger('unload', 'delete messages');
await model.destroyMessages();
model.updateLastMessage();
},
});
},
reject: () => {
log.info('destroyMessages: User canceled delete');
},
});
}
async isCallSafe(): Promise<boolean> {
const recipientsByConversation = {
[this.model.id]: {