diff --git a/app/attachment_channel.ts b/app/attachment_channel.ts index e33702f086..35efb1de55 100644 --- a/app/attachment_channel.ts +++ b/app/attachment_channel.ts @@ -8,7 +8,7 @@ import { getStickersPath, getTempPath, getDraftPath, -} from '../ts/util/attachments'; +} from './attachments'; let initialized = false; diff --git a/app/attachments.ts b/app/attachments.ts index 0126bdcfa1..7a7bc11e8a 100644 --- a/app/attachments.ts +++ b/app/attachments.ts @@ -1,25 +1,77 @@ // Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { join, relative } from 'path'; - +import { join, relative, normalize } from 'path'; import fastGlob from 'fast-glob'; import glob from 'glob'; import pify from 'pify'; import fse from 'fs-extra'; -import { map } from 'lodash'; +import { map, isString } from 'lodash'; import normalizePath from 'normalize-path'; +import { isPathInside } from '../ts/util/isPathInside'; -import { - getPath, - getStickersPath, - getBadgesPath, - getDraftPath, - getTempPath, - createDeleter, -} from '../ts/util/attachments'; +const PATH = 'attachments.noindex'; +const AVATAR_PATH = 'avatars.noindex'; +const BADGES_PATH = 'badges.noindex'; +const STICKER_PATH = 'stickers.noindex'; +const TEMP_PATH = 'temp'; +const UPDATE_CACHE_PATH = 'update-cache'; +const DRAFT_PATH = 'drafts.noindex'; -export * from '../ts/util/attachments'; +const CACHED_PATHS = new Map(); + +const createPathGetter = + (subpath: string) => + (userDataPath: string): string => { + if (!isString(userDataPath)) { + throw new TypeError("'userDataPath' must be a string"); + } + + const naivePath = join(userDataPath, subpath); + + const cached = CACHED_PATHS.get(naivePath); + if (cached) { + return cached; + } + + let result = naivePath; + if (fse.pathExistsSync(naivePath)) { + result = fse.realpathSync(naivePath); + } + + CACHED_PATHS.set(naivePath, result); + + return result; + }; + +export const getAvatarsPath = createPathGetter(AVATAR_PATH); +export const getBadgesPath = createPathGetter(BADGES_PATH); +export const getDraftPath = createPathGetter(DRAFT_PATH); +export const getPath = createPathGetter(PATH); +export const getStickersPath = createPathGetter(STICKER_PATH); +export const getTempPath = createPathGetter(TEMP_PATH); +export const getUpdateCachePath = createPathGetter(UPDATE_CACHE_PATH); + +export const createDeleter = ( + root: string +): ((relativePath: string) => Promise) => { + if (!isString(root)) { + throw new TypeError("'root' must be a path"); + } + + return async (relativePath: string): Promise => { + if (!isString(relativePath)) { + throw new TypeError("'relativePath' must be a string"); + } + + const absolutePath = join(root, relativePath); + const normalized = normalize(absolutePath); + if (!isPathInside(normalized, root)) { + throw new Error('Invalid relative path'); + } + await fse.remove(absolutePath); + }; +}; export const getAllAttachments = async ( userDataPath: string diff --git a/app/protocol_filter.ts b/app/protocol_filter.ts index 896d85f6ce..d5ff3691aa 100644 --- a/app/protocol_filter.ts +++ b/app/protocol_filter.ts @@ -17,7 +17,7 @@ import { getStickersPath, getTempPath, getUpdateCachePath, -} from '../ts/util/attachments'; +} from './attachments'; type CallbackType = (response: string | ProtocolResponse) => void; diff --git a/ts/badges/shouldShowBadges.ts b/ts/badges/shouldShowBadges.ts deleted file mode 100644 index bc4fa43869..0000000000 --- a/ts/badges/shouldShowBadges.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { isEnabled } from '../RemoteConfig'; -import { getEnvironment, Environment } from '../environment'; -import { isBeta } from '../util/version'; - -export function shouldShowBadges(): boolean { - if ( - isEnabled('desktop.showUserBadges2') || - isEnabled('desktop.internalUser') || - getEnvironment() === Environment.Staging || - getEnvironment() === Environment.Development || - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Boolean((window as any).STORYBOOK_ENV) - ) { - return true; - } - - if (isEnabled('desktop.showUserBadges.beta') && isBeta(window.getVersion())) { - return true; - } - - return false; -} diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index 12878b425f..57d61ae6a9 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -25,7 +25,6 @@ import { getBadgeImageFileLocalPath } from '../badges/getBadgeImageFileLocalPath import { getInitials } from '../util/getInitials'; import { isBadgeVisible } from '../badges/isBadgeVisible'; import { shouldBlurAvatar } from '../util/shouldBlurAvatar'; -import { shouldShowBadges } from '../badges/shouldShowBadges'; export enum AvatarBlur { NoBlur, @@ -248,14 +247,7 @@ export const Avatar: FunctionComponent = ({ let badgeNode: ReactNode; const badgeSize = _getBadgeSize(size); - if ( - badge && - theme && - !noteToSelf && - badgeSize && - isBadgeVisible(badge) && - shouldShowBadges() - ) { + if (badge && theme && !noteToSelf && badgeSize && isBadgeVisible(badge)) { const badgePlacement = _getBadgePlacement(size); const badgeTheme = theme === ThemeType.light ? BadgeImageTheme.Light : BadgeImageTheme.Dark; diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index c28d7be975..5504ddeccd 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -151,6 +151,7 @@ export const OngoingGroupCall = (): JSX.Element => ( joinState: GroupCallJoinState.Joined, maxDevices: 5, groupMembers: [], + isConversationTooBigToRing: false, peekedParticipants: [], remoteParticipants: [], remoteAudioLevels: new Map(), @@ -234,6 +235,7 @@ export const GroupCallSafetyNumberChanged = (): JSX.Element => ( joinState: GroupCallJoinState.Joined, maxDevices: 5, groupMembers: [], + isConversationTooBigToRing: false, peekedParticipants: [], remoteParticipants: [], remoteAudioLevels: new Map(), diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 0efa526ff7..1c8cdf8760 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -197,6 +197,7 @@ const ActiveCallManager: React.FC = ({ let groupMembers: | undefined | Array>; + let isConversationTooBigToRing = false; switch (activeCall.callMode) { case CallMode.Direct: { @@ -222,6 +223,7 @@ const ActiveCallManager: React.FC = ({ case CallMode.Group: { showCallLobby = activeCall.joinState !== GroupCallJoinState.Joined; isCallFull = activeCall.deviceCount >= activeCall.maxDevices; + isConversationTooBigToRing = activeCall.isConversationTooBigToRing; ({ groupMembers } = activeCall); break; } @@ -242,6 +244,7 @@ const ActiveCallManager: React.FC = ({ isGroupCall={activeCall.callMode === CallMode.Group} isGroupCallOutboundRingEnabled={isGroupCallOutboundRingEnabled} isCallFull={isCallFull} + isConversationTooBigToRing={isConversationTooBigToRing} me={me} onCallCanceled={cancelActiveCall} onJoinCall={joinActiveCall} diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index bd37389dd8..e0bc727b7b 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -101,6 +101,7 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({ groupMembers: overrideProps.remoteParticipants || [], // Because remote participants are a superset, we can use them in place of peeked // participants. + isConversationTooBigToRing: false, peekedParticipants: overrideProps.peekedParticipants || overrideProps.remoteParticipants || [], remoteParticipants: overrideProps.remoteParticipants || [], diff --git a/ts/components/CallingLobby.stories.tsx b/ts/components/CallingLobby.stories.tsx index faef369521..1714c412d1 100644 --- a/ts/components/CallingLobby.stories.tsx +++ b/ts/components/CallingLobby.stories.tsx @@ -59,6 +59,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => { i18n, isGroupCall, isGroupCallOutboundRingEnabled: true, + isConversationTooBigToRing: false, isCallFull: boolean('isCallFull', overrideProps.isCallFull || false), me: overrideProps.me || diff --git a/ts/components/CallingLobby.tsx b/ts/components/CallingLobby.tsx index 4e9455d5b1..491ae3d0c0 100644 --- a/ts/components/CallingLobby.tsx +++ b/ts/components/CallingLobby.tsx @@ -23,7 +23,6 @@ import type { LocalizerType } from '../types/Util'; import { useIsOnline } from '../hooks/useIsOnline'; import * as KeyboardLayout from '../services/keyboardLayout'; import type { ConversationType } from '../state/ducks/conversations'; -import { isConversationTooBigToRing } from '../conversations/isConversationTooBigToRing'; export type PropsType = { availableCameras: Array; @@ -46,6 +45,7 @@ export type PropsType = { hasLocalAudio: boolean; hasLocalVideo: boolean; i18n: LocalizerType; + isConversationTooBigToRing: boolean; isGroupCall: boolean; isGroupCallOutboundRingEnabled: boolean; isCallFull?: boolean; @@ -73,6 +73,7 @@ export const CallingLobby = ({ isGroupCall = false, isGroupCallOutboundRingEnabled, isCallFull = false, + isConversationTooBigToRing, me, onCallCanceled, onJoinCall, @@ -166,8 +167,6 @@ export const CallingLobby = ({ ? CallingButtonType.AUDIO_ON : CallingButtonType.AUDIO_OFF; - const isGroupTooLargeToRing = isConversationTooBigToRing(conversation); - const isRingButtonVisible: boolean = isGroupCall && isGroupCallOutboundRingEnabled && @@ -177,7 +176,7 @@ export const CallingLobby = ({ let preCallInfoRingMode: RingMode; if (isGroupCall) { preCallInfoRingMode = - outgoingRing && !isGroupTooLargeToRing + outgoingRing && !isConversationTooBigToRing ? RingMode.WillRing : RingMode.WillNotRing; } else { @@ -189,7 +188,7 @@ export const CallingLobby = ({ | CallingButtonType.RING_ON | CallingButtonType.RING_OFF; if (isRingButtonVisible) { - if (isGroupTooLargeToRing) { + if (isConversationTooBigToRing) { ringButtonType = CallingButtonType.RING_DISABLED; } else if (outgoingRing) { ringButtonType = CallingButtonType.RING_ON; diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx index 546fa4f8e8..00b20f7246 100644 --- a/ts/components/CallingPip.stories.tsx +++ b/ts/components/CallingPip.stories.tsx @@ -130,6 +130,7 @@ export const GroupCall = (): JSX.Element => { connectionState: GroupCallConnectionState.Connected, conversationsWithSafetyNumberChanges: [], groupMembers: times(3, () => getDefaultConversation()), + isConversationTooBigToRing: false, joinState: GroupCallJoinState.Joined, maxDevices: 5, deviceCount: 0, diff --git a/ts/components/EditUsernameModalBody.stories.tsx b/ts/components/EditUsernameModalBody.stories.tsx index 767b5bd449..63dbc46ea8 100644 --- a/ts/components/EditUsernameModalBody.stories.tsx +++ b/ts/components/EditUsernameModalBody.stories.tsx @@ -54,6 +54,12 @@ export default { General: UsernameReservationError.General, }, }, + maxUsername: { + defaultValue: 20, + }, + minUsername: { + defaultValue: 3, + }, discriminator: { type: { name: 'string', required: false }, defaultValue: undefined, diff --git a/ts/components/EditUsernameModalBody.tsx b/ts/components/EditUsernameModalBody.tsx index 603fe3f566..6db309deee 100644 --- a/ts/components/EditUsernameModalBody.tsx +++ b/ts/components/EditUsernameModalBody.tsx @@ -7,12 +7,7 @@ import classNames from 'classnames'; import type { LocalizerType } from '../types/Util'; import type { UsernameReservationType } from '../types/Username'; import { missingCaseError } from '../util/missingCaseError'; -import { - getNickname, - getDiscriminator, - getMinNickname, - getMaxNickname, -} from '../util/Username'; +import { getNickname, getDiscriminator } from '../types/Username'; import { UsernameReservationState, UsernameReservationError, @@ -30,6 +25,8 @@ export type PropsDataType = Readonly<{ reservation?: UsernameReservationType; error?: UsernameReservationError; state: UsernameReservationState; + minNickname: number; + maxNickname: number; }>; export type ActionPropsDataType = Readonly<{ @@ -53,6 +50,8 @@ export const EditUsernameModalBody = ({ currentUsername, reserveUsername, confirmUsername, + minNickname, + maxNickname, reservation, setUsernameReservationError, error, @@ -103,12 +102,12 @@ export const EditUsernameModalBody = ({ } if (error === UsernameReservationError.NotEnoughCharacters) { return i18n('ProfileEditor--username--check-character-min', { - min: getMinNickname(), + min: minNickname, }); } if (error === UsernameReservationError.TooManyCharacters) { return i18n('ProfileEditor--username--check-character-max', { - max: getMaxNickname(), + max: maxNickname, }); } if (error === UsernameReservationError.CheckStartingCharacter) { @@ -125,7 +124,7 @@ export const EditUsernameModalBody = ({ return; } throw missingCaseError(error); - }, [error, i18n]); + }, [error, i18n, minNickname, maxNickname]); useEffect(() => { // Initial effect run diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index a19b7b5025..ffddfd18cd 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -881,6 +881,8 @@ export const ChooseGroupMembersPartialPhoneNumber = (): JSX.Element => ( mode: LeftPaneMode.ChooseGroupMembers, uuidFetchState: {}, candidateContacts: [], + groupSizeRecommendedLimit: 151, + groupSizeHardLimit: 1001, isShowingRecommendedGroupSizeModal: false, isShowingMaximumGroupSizeModal: false, isUsernamesEnabled: true, @@ -903,6 +905,8 @@ export const ChooseGroupMembersValidPhoneNumber = (): JSX.Element => ( mode: LeftPaneMode.ChooseGroupMembers, uuidFetchState: {}, candidateContacts: [], + groupSizeRecommendedLimit: 151, + groupSizeHardLimit: 1001, isShowingRecommendedGroupSizeModal: false, isShowingMaximumGroupSizeModal: false, isUsernamesEnabled: true, @@ -925,6 +929,8 @@ export const ChooseGroupMembersUsername = (): JSX.Element => ( mode: LeftPaneMode.ChooseGroupMembers, uuidFetchState: {}, candidateContacts: [], + groupSizeRecommendedLimit: 151, + groupSizeHardLimit: 1001, isShowingRecommendedGroupSizeModal: false, isShowingMaximumGroupSizeModal: false, isUsernamesEnabled: true, diff --git a/ts/components/ProfileEditor.stories.tsx b/ts/components/ProfileEditor.stories.tsx index fa028d3b1e..9325e4c305 100644 --- a/ts/components/ProfileEditor.stories.tsx +++ b/ts/components/ProfileEditor.stories.tsx @@ -92,6 +92,8 @@ function renderEditUsernameModalBody(props: { return ( ) => { action('onMakeRequest')(conversationIds); }, + maxGroupSize: 1001, + maxRecommendedGroupSize: 151, requestState: RequestState.Inactive, renderChooseGroupMembersModal: props => { const { selectedConversationIds } = props; diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal.tsx index f83cfb9602..2ac27352c2 100644 --- a/ts/components/conversation/conversation-details/AddGroupMembersModal.tsx +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal.tsx @@ -12,10 +12,6 @@ import { } from '../../AddGroupMemberErrorDialog'; import type { SmartChooseGroupMembersModalPropsType } from '../../../state/smart/ChooseGroupMembersModal'; import type { SmartConfirmAdditionsModalPropsType } from '../../../state/smart/ConfirmAdditionsModal'; -import { - getGroupSizeRecommendedLimit, - getGroupSizeHardLimit, -} from '../../../groups/limits'; import { toggleSelectedContactForGroupAddition, OneTimeModalState, @@ -31,6 +27,8 @@ type PropsType = { makeRequest: (conversationIds: ReadonlyArray) => Promise; onClose: () => void; requestState: RequestState; + maxGroupSize: number; + maxRecommendedGroupSize: number; renderChooseGroupMembersModal: ( props: SmartChooseGroupMembersModalPropsType @@ -46,6 +44,8 @@ enum Stage { } type StateType = { + maxGroupSize: number; + maxRecommendedGroupSize: number; maximumGroupSizeModalState: OneTimeModalState; recommendedGroupSizeModalState: OneTimeModalState; searchTerm: string; @@ -116,8 +116,8 @@ function reducer( return { ...state, ...toggleSelectedContactForGroupAddition(action.conversationId, { - maxGroupSize: getMaximumNumberOfContacts(), - maxRecommendedGroupSize: getRecommendedMaximumNumberOfContacts(), + maxGroupSize: state.maxGroupSize, + maxRecommendedGroupSize: state.maxRecommendedGroupSize, maximumGroupSizeModalState: state.maximumGroupSizeModalState, numberOfContactsAlreadyInGroup: action.numberOfContactsAlreadyInGroup, recommendedGroupSizeModalState: state.recommendedGroupSizeModalState, @@ -141,13 +141,12 @@ export const AddGroupMembersModal: FunctionComponent = ({ i18n, onClose, makeRequest, + maxGroupSize, + maxRecommendedGroupSize, requestState, renderChooseGroupMembersModal, renderConfirmAdditionsModal, }) => { - const maxGroupSize = getMaximumNumberOfContacts(); - const maxRecommendedGroupSize = getRecommendedMaximumNumberOfContacts(); - const numberOfContactsAlreadyInGroup = conversationIdsAlreadyInGroup.size; const isGroupAlreadyFull = numberOfContactsAlreadyInGroup >= maxGroupSize; const isGroupAlreadyOverRecommendedMaximum = @@ -163,6 +162,8 @@ export const AddGroupMembersModal: FunctionComponent = ({ }, dispatch, ] = useReducer(reducer, { + maxGroupSize, + maxRecommendedGroupSize, maximumGroupSizeModalState: isGroupAlreadyFull ? OneTimeModalState.Showing : OneTimeModalState.NeverShown, @@ -260,11 +261,3 @@ export const AddGroupMembersModal: FunctionComponent = ({ throw missingCaseError(stage); } }; - -function getRecommendedMaximumNumberOfContacts(): number { - return getGroupSizeRecommendedLimit(151); -} - -function getMaximumNumberOfContacts(): number { - return getGroupSizeHardLimit(1001); -} diff --git a/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx index a73ebef584..d6bcc3e898 100644 --- a/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx +++ b/ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx @@ -14,7 +14,7 @@ import type { MeasuredComponentProps } from 'react-measure'; import Measure from 'react-measure'; import type { LocalizerType, ThemeType } from '../../../../types/Util'; -import { getUsernameFromSearch } from '../../../../util/Username'; +import { getUsernameFromSearch } from '../../../../types/Username'; import { refMerger } from '../../../../util/refMerger'; import { useRestoreFocus } from '../../../../hooks/useRestoreFocus'; import { missingCaseError } from '../../../../util/missingCaseError'; diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index dc6f74ba9a..bc05c81dae 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -62,6 +62,8 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({ isMe: i === 2, }), })), + maxGroupSize: 1001, + maxRecommendedGroupSize: 151, pendingApprovalMemberships: times(8, () => ({ member: getDefaultConversation(), })), diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index 927a794dc5..e56c3ea62c 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -74,6 +74,8 @@ export type StateProps = { isGroup: boolean; loadRecentMediaItems: (limit: number) => void; groupsInCommon: Array; + maxGroupSize: number; + maxRecommendedGroupSize: number; memberships: Array; pendingApprovalMemberships: ReadonlyArray; pendingMemberships: ReadonlyArray; @@ -141,6 +143,8 @@ export const ConversationDetails: React.ComponentType = ({ isGroup, loadRecentMediaItems, memberships, + maxGroupSize, + maxRecommendedGroupSize, onBlock, onLeave, onOutgoingAudioCallInConversation, @@ -272,6 +276,8 @@ export const ConversationDetails: React.ComponentType = ({ setAddGroupMembersRequestState(RequestState.InactiveWithError); } }} + maxGroupSize={maxGroupSize} + maxRecommendedGroupSize={maxRecommendedGroupSize} onClose={() => { setModalState(ModalState.NothingOpen); setEditGroupAttributesRequestState(RequestState.Inactive); diff --git a/ts/components/conversationList/UsernameSearchResultListItem.tsx b/ts/components/conversationList/UsernameSearchResultListItem.tsx index 38a91ad5fe..cf9cc5de5d 100644 --- a/ts/components/conversationList/UsernameSearchResultListItem.tsx +++ b/ts/components/conversationList/UsernameSearchResultListItem.tsx @@ -7,7 +7,6 @@ import React, { useCallback } from 'react'; import { BaseConversationListItem } from './BaseConversationListItem'; import type { LocalizerType } from '../../types/Util'; -import { lookupConversationWithoutUuid } from '../../util/lookupConversationWithoutUuid'; import type { LookupConversationWithoutUuidActionsType } from '../../util/lookupConversationWithoutUuid'; import type { ShowConversationType } from '../../state/ducks/conversations'; @@ -26,6 +25,7 @@ export type Props = PropsData & PropsHousekeeping; export const UsernameSearchResultListItem: FunctionComponent = ({ i18n, isFetchingUsername, + lookupConversationWithoutUuid, username, showUserNotFoundModal, setIsFetchingUUID, @@ -48,11 +48,12 @@ export const UsernameSearchResultListItem: FunctionComponent = ({ showConversation({ conversationId }); } }, [ - username, - showUserNotFoundModal, + isFetchingUsername, + lookupConversationWithoutUuid, setIsFetchingUUID, showConversation, - isFetchingUsername, + showUserNotFoundModal, + username, ]); return ( diff --git a/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx b/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx index f31de961fe..d78e3c7506 100644 --- a/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx +++ b/ts/components/leftPane/LeftPaneChooseGroupMembersHelper.tsx @@ -18,7 +18,7 @@ import { } from '../AddGroupMemberErrorDialog'; import { Button } from '../Button'; import type { LocalizerType } from '../../types/Util'; -import { getUsernameFromSearch } from '../../util/Username'; +import { getUsernameFromSearch } from '../../types/Username'; import type { ParsedE164Type } from '../../util/libphonenumberInstance'; import { parseAndFormatPhoneNumber } from '../../util/libphonenumberInstance'; import type { UUIDFetchStateType } from '../../util/uuidFetchState'; @@ -26,14 +26,12 @@ import { isFetchingByUsername, isFetchingByE164, } from '../../util/uuidFetchState'; -import { - getGroupSizeRecommendedLimit, - getGroupSizeHardLimit, -} from '../../groups/limits'; export type LeftPaneChooseGroupMembersPropsType = { uuidFetchState: UUIDFetchStateType; candidateContacts: ReadonlyArray; + groupSizeRecommendedLimit: number; + groupSizeHardLimit: number; isShowingRecommendedGroupSizeModal: boolean; isShowingMaximumGroupSizeModal: boolean; isUsernamesEnabled: boolean; @@ -53,6 +51,10 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper @@ -203,7 +209,7 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper @@ -393,20 +399,12 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper= this.getMaximumNumberOfContacts(); + return this.selectedContacts.length >= this.groupSizeHardLimit; } private hasExceededMaximumNumberOfContacts(): boolean { // It should be impossible to reach this state. This is here as a failsafe. - return this.selectedContacts.length > this.getMaximumNumberOfContacts(); - } - - private getRecommendedMaximumNumberOfContacts(): number { - return getGroupSizeRecommendedLimit(151) - 1; - } - - private getMaximumNumberOfContacts(): number { - return getGroupSizeHardLimit(1001) - 1; + return this.selectedContacts.length > this.groupSizeHardLimit; } } diff --git a/ts/components/leftPane/LeftPaneComposeHelper.tsx b/ts/components/leftPane/LeftPaneComposeHelper.tsx index 239b5b466a..80fd6969c7 100644 --- a/ts/components/leftPane/LeftPaneComposeHelper.tsx +++ b/ts/components/leftPane/LeftPaneComposeHelper.tsx @@ -14,7 +14,7 @@ import type { LocalizerType } from '../../types/Util'; import type { ParsedE164Type } from '../../util/libphonenumberInstance'; import { parseAndFormatPhoneNumber } from '../../util/libphonenumberInstance'; import { missingCaseError } from '../../util/missingCaseError'; -import { getUsernameFromSearch } from '../../util/Username'; +import { getUsernameFromSearch } from '../../types/Username'; import type { UUIDFetchStateType } from '../../util/uuidFetchState'; import { isFetchingByUsername, diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index 5f0a6688bb..c8364636d0 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -36,6 +36,7 @@ import { } from '../../services/notifications'; import * as log from '../../logging/log'; import { getPreferredBadgeSelector } from '../selectors/badges'; +import { isConversationTooBigToRing } from '../../conversations/isConversationTooBigToRing'; function renderDeviceSelection(): JSX.Element { return ; @@ -260,6 +261,7 @@ const mapStateToActiveCallProp = ( conversationsWithSafetyNumberChanges, deviceCount: peekInfo.deviceCount, groupMembers, + isConversationTooBigToRing: isConversationTooBigToRing(conversation), joinState: call.joinState, maxDevices: peekInfo.maxDevices, peekedParticipants, diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index 189be39763..20892097a3 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -30,6 +30,10 @@ import type { SmartChooseGroupMembersModalPropsType } from './ChooseGroupMembers import { SmartChooseGroupMembersModal } from './ChooseGroupMembersModal'; import type { SmartConfirmAdditionsModalPropsType } from './ConfirmAdditionsModal'; import { SmartConfirmAdditionsModal } from './ConfirmAdditionsModal'; +import { + getGroupSizeRecommendedLimit, + getGroupSizeHardLimit, +} from '../../groups/limits'; export type SmartConversationDetailsProps = { addMembers: (conversationIds: ReadonlyArray) => Promise; @@ -114,6 +118,9 @@ const mapStateToProps = ( const groupsInCommonSorted = sortBy(groupsInCommon, 'title'); + const maxGroupSize = getGroupSizeHardLimit(1001); + const maxRecommendedGroupSize = getGroupSizeRecommendedLimit(151); + return { ...props, areWeASubscriber: getAreWeASubscriber(state), @@ -129,6 +136,8 @@ const mapStateToProps = ( i18n: getIntl(state), isAdmin, ...groupMemberships, + maxGroupSize, + maxRecommendedGroupSize, userAvatarData: conversation.avatars || [], hasGroupLink, groupsInCommon: groupsInCommonSorted, diff --git a/ts/state/smart/EditUsernameModalBody.tsx b/ts/state/smart/EditUsernameModalBody.tsx index 226b5ce693..81a5e4afc7 100644 --- a/ts/state/smart/EditUsernameModalBody.tsx +++ b/ts/state/smart/EditUsernameModalBody.tsx @@ -6,6 +6,7 @@ import { mapDispatchToProps } from '../actions'; import type { PropsDataType } from '../../components/EditUsernameModalBody'; import { EditUsernameModalBody } from '../../components/EditUsernameModalBody'; +import { getMinNickname, getMaxNickname } from '../../util/Username'; import type { StateType } from '../reducer'; import { getIntl } from '../selectors/user'; @@ -23,6 +24,8 @@ function mapStateToProps(state: StateType): PropsDataType { return { i18n, currentUsername: username, + minNickname: getMinNickname(), + maxNickname: getMaxNickname(), state: getUsernameReservationState(state), reservation: getUsernameReservationObject(state), error: getUsernameReservationError(state), diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index 8926425298..17c5ba9aee 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -49,6 +49,10 @@ import { isEditingAvatar, } from '../selectors/conversations'; import type { WidthBreakpoint } from '../../components/_util'; +import { + getGroupSizeRecommendedLimit, + getGroupSizeHardLimit, +} from '../../groups/limits'; import { SmartExpiredBuildDialog } from './ExpiredBuildDialog'; import { SmartMainHeader } from './MainHeader'; @@ -148,6 +152,8 @@ const getModeSpecificProps = ( return { mode: LeftPaneMode.ChooseGroupMembers, candidateContacts: getFilteredCandidateContactsForNewGroup(state), + groupSizeRecommendedLimit: getGroupSizeRecommendedLimit(), + groupSizeHardLimit: getGroupSizeHardLimit(), isShowingRecommendedGroupSizeModal: getRecommendedGroupSizeModalState(state) === OneTimeModalState.Showing, diff --git a/ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts index e107a4cccb..3a74a9ea68 100644 --- a/ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts +++ b/ts/test-node/components/leftPane/LeftPaneChooseGroupMembersHelper_test.ts @@ -9,7 +9,6 @@ import { ContactCheckboxDisabledReason } from '../../../components/conversationL import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; import { LeftPaneChooseGroupMembersHelper } from '../../../components/leftPane/LeftPaneChooseGroupMembersHelper'; -import { updateRemoteConfig } from '../../../test-both/helpers/RemoteConfigStub'; describe('LeftPaneChooseGroupMembersHelper', () => { const defaults = { @@ -18,22 +17,13 @@ describe('LeftPaneChooseGroupMembersHelper', () => { isShowingRecommendedGroupSizeModal: false, isShowingMaximumGroupSizeModal: false, isUsernamesEnabled: true, + groupSizeRecommendedLimit: 22, + groupSizeHardLimit: 33, searchTerm: '', regionCode: 'US', selectedContacts: [], }; - beforeEach(async () => { - await updateRemoteConfig([ - { name: 'global.groupsv2.maxGroupSize', value: '22', enabled: true }, - { - name: 'global.groupsv2.groupSizeHardLimit', - value: '33', - enabled: true, - }, - ]); - }); - describe('getBackAction', () => { it('returns the "show composer" action', () => { const startComposing = sinon.fake(); diff --git a/ts/test-node/types/Username_test.ts b/ts/test-node/types/Username_test.ts new file mode 100644 index 0000000000..c2f56e1b28 --- /dev/null +++ b/ts/test-node/types/Username_test.ts @@ -0,0 +1,54 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import * as Username from '../../types/Username'; + +describe('Username', () => { + describe('getUsernameFromSearch', () => { + const { getUsernameFromSearch } = Username; + + it('matches invalid username searches', () => { + assert.strictEqual(getUsernameFromSearch('use'), 'use'); + assert.strictEqual( + getUsernameFromSearch('username9012345678901234567'), + 'username9012345678901234567' + ); + }); + + it('matches valid username searches', () => { + assert.strictEqual(getUsernameFromSearch('username_34'), 'username_34'); + assert.strictEqual(getUsernameFromSearch('u5ername'), 'u5ername'); + assert.strictEqual(getUsernameFromSearch('username.12'), 'username.12'); + assert.strictEqual(getUsernameFromSearch('user'), 'user'); + assert.strictEqual( + getUsernameFromSearch('username901234567890123456'), + 'username901234567890123456' + ); + }); + + it('matches valid and invalid usernames with @ prefix', () => { + assert.strictEqual(getUsernameFromSearch('@username!'), 'username!'); + assert.strictEqual(getUsernameFromSearch('@1username'), '1username'); + assert.strictEqual(getUsernameFromSearch('@username_34'), 'username_34'); + assert.strictEqual(getUsernameFromSearch('@username.34'), 'username.34'); + assert.strictEqual(getUsernameFromSearch('@u5ername'), 'u5ername'); + }); + + it('matches valid and invalid usernames with @ suffix', () => { + assert.strictEqual(getUsernameFromSearch('username!@'), 'username!'); + assert.strictEqual(getUsernameFromSearch('1username@'), '1username'); + assert.strictEqual(getUsernameFromSearch('username_34@'), 'username_34'); + assert.strictEqual(getUsernameFromSearch('username.34@'), 'username.34'); + assert.strictEqual(getUsernameFromSearch('u5ername@'), 'u5ername'); + }); + + it('does not match something that looks like a phone number', () => { + assert.isUndefined(getUsernameFromSearch('+')); + assert.isUndefined(getUsernameFromSearch('2223')); + assert.isUndefined(getUsernameFromSearch('+3')); + assert.isUndefined(getUsernameFromSearch('+234234234233')); + }); + }); +}); diff --git a/ts/test-node/util/Username_test.ts b/ts/test-node/util/Username_test.ts index 3560a0d43e..cdec181267 100644 --- a/ts/test-node/util/Username_test.ts +++ b/ts/test-node/util/Username_test.ts @@ -6,52 +6,6 @@ import { assert } from 'chai'; import * as Username from '../../util/Username'; describe('Username', () => { - describe('getUsernameFromSearch', () => { - const { getUsernameFromSearch } = Username; - - it('matches invalid username searches', () => { - assert.strictEqual(getUsernameFromSearch('use'), 'use'); - assert.strictEqual( - getUsernameFromSearch('username9012345678901234567'), - 'username9012345678901234567' - ); - }); - - it('matches valid username searches', () => { - assert.strictEqual(getUsernameFromSearch('username_34'), 'username_34'); - assert.strictEqual(getUsernameFromSearch('u5ername'), 'u5ername'); - assert.strictEqual(getUsernameFromSearch('username.12'), 'username.12'); - assert.strictEqual(getUsernameFromSearch('user'), 'user'); - assert.strictEqual( - getUsernameFromSearch('username901234567890123456'), - 'username901234567890123456' - ); - }); - - it('matches valid and invalid usernames with @ prefix', () => { - assert.strictEqual(getUsernameFromSearch('@username!'), 'username!'); - assert.strictEqual(getUsernameFromSearch('@1username'), '1username'); - assert.strictEqual(getUsernameFromSearch('@username_34'), 'username_34'); - assert.strictEqual(getUsernameFromSearch('@username.34'), 'username.34'); - assert.strictEqual(getUsernameFromSearch('@u5ername'), 'u5ername'); - }); - - it('matches valid and invalid usernames with @ suffix', () => { - assert.strictEqual(getUsernameFromSearch('username!@'), 'username!'); - assert.strictEqual(getUsernameFromSearch('1username@'), '1username'); - assert.strictEqual(getUsernameFromSearch('username_34@'), 'username_34'); - assert.strictEqual(getUsernameFromSearch('username.34@'), 'username.34'); - assert.strictEqual(getUsernameFromSearch('u5ername@'), 'u5ername'); - }); - - it('does not match something that looks like a phone number', () => { - assert.isUndefined(getUsernameFromSearch('+')); - assert.isUndefined(getUsernameFromSearch('2223')); - assert.isUndefined(getUsernameFromSearch('+3')); - assert.isUndefined(getUsernameFromSearch('+234234234233')); - }); - }); - describe('isValidUsername', () => { const { isValidUsername } = Username; diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index 5b805f348f..02e8fe4c84 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -15,7 +15,6 @@ import { blobToArrayBuffer } from 'blob-util'; import type { LoggerType } from './Logging'; import * as MIME from './MIME'; -import * as log from '../logging/log'; import { toLogFormat } from './errors'; import { SignalService } from '../protobuf'; import { @@ -24,11 +23,7 @@ import { } from '../util/GoogleChrome'; import type { LocalizerType } from './Util'; import { ThemeType } from './Util'; -import { scaleImageToLevel } from '../util/scaleImageToLevel'; import * as GoogleChrome from '../util/GoogleChrome'; -import { parseIntOrThrow } from '../util/parseIntOrThrow'; -import { getValue } from '../RemoteConfig'; -import { isRecord } from '../util/isRecord'; import { ReadStatus } from '../messages/MessageReadStatus'; import type { MessageStatusType } from '../components/conversation/Message'; @@ -249,73 +244,6 @@ export function isValid( return true; } -// Upgrade steps -// NOTE: This step strips all EXIF metadata from JPEG images as -// part of re-encoding the image: -export async function autoOrientJPEG( - attachment: AttachmentType, - { logger }: { logger: LoggerType }, - { - sendHQImages = false, - isIncoming = false, - }: { - sendHQImages?: boolean; - isIncoming?: boolean; - } = {} -): Promise { - if (isIncoming && !MIME.isJPEG(attachment.contentType)) { - return attachment; - } - - if (!canBeTranscoded(attachment)) { - return attachment; - } - - // If we haven't downloaded the attachment yet, we won't have the data. - // All images go through handleImageAttachment before being sent and thus have - // already been scaled to level, oriented, stripped of exif data, and saved - // in high quality format. If we want to send the image in HQ we can return - // the attachment as-is. Otherwise we'll have to further scale it down. - if (!attachment.data || sendHQImages) { - return attachment; - } - - const dataBlob = new Blob([attachment.data], { - type: attachment.contentType, - }); - try { - const { blob: xcodedDataBlob } = await scaleImageToLevel( - dataBlob, - attachment.contentType, - isIncoming - ); - const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob); - - // IMPORTANT: We overwrite the existing `data` `Uint8Array` losing the original - // image data. Ideally, we’d preserve the original image data for users who want to - // retain it but due to reports of data loss, we don’t want to overburden IndexedDB - // by potentially doubling stored image data. - // See: https://github.com/signalapp/Signal-Desktop/issues/1589 - const xcodedAttachment = { - // `digest` is no longer valid for auto-oriented image data, so we discard it: - ...omit(attachment, 'digest'), - data: new Uint8Array(xcodedDataArrayBuffer), - size: xcodedDataArrayBuffer.byteLength, - }; - - return xcodedAttachment; - } catch (error: unknown) { - const errorString = - isRecord(error) && 'stack' in error ? error.stack : error; - logger.error( - 'autoOrientJPEG: Failed to rotate/scale attachment', - errorString - ); - - return attachment; - } -} - const UNICODE_LEFT_TO_RIGHT_OVERRIDE = '\u202D'; const UNICODE_RIGHT_TO_LEFT_OVERRIDE = '\u202E'; const UNICODE_REPLACEMENT_CHARACTER = '\uFFFD'; @@ -1047,23 +975,6 @@ export const getFileExtension = ( } }; -const MEBIBYTE = 1024 * 1024; -const DEFAULT_MAX = 100 * MEBIBYTE; - -export const getMaximumAttachmentSize = (): number => { - try { - return parseIntOrThrow( - getValue('global.attachments.maxBytes'), - 'preProcessAttachment/maxAttachmentSize' - ); - } catch (error) { - log.warn( - 'Failed to parse integer out of global.attachments.maxBytes feature flag' - ); - return DEFAULT_MAX; - } -}; - export const defaultBlurHash = (theme: ThemeType = ThemeType.light): string => { if (theme === ThemeType.dark) { return 'L05OQnoffQofoffQfQfQfQfQfQfQ'; diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index b6ed3b3273..7a7580449f 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -72,6 +72,7 @@ type ActiveGroupCallType = ActiveCallBaseType & { maxDevices: number; deviceCount: number; groupMembers: Array>; + isConversationTooBigToRing: boolean; peekedParticipants: Array; remoteParticipants: Array; remoteAudioLevels: Map; diff --git a/ts/types/Message2.ts b/ts/types/Message2.ts index 414b609160..56ded98b0b 100644 --- a/ts/types/Message2.ts +++ b/ts/types/Message2.ts @@ -5,8 +5,8 @@ import { isFunction, isObject, isString, omit } from 'lodash'; import * as Contact from './EmbeddedContact'; import type { AttachmentType, AttachmentWithHydratedData } from './Attachment'; +import { autoOrientJPEG } from '../util/attachments'; import { - autoOrientJPEG, captureDimensionsAndScreenshot, hasData, migrateDataToFileSystem, diff --git a/ts/types/Username.ts b/ts/types/Username.ts index 7c4459ebad..43d881c40e 100644 --- a/ts/types/Username.ts +++ b/ts/types/Username.ts @@ -11,3 +11,41 @@ export enum ReserveUsernameError { Unprocessable = 'Unprocessable', Conflict = 'Conflict', } + +export function getUsernameFromSearch(searchTerm: string): string | undefined { + // Search term contains username if it: + // - Is a valid username with or without a discriminator + // - Starts with @ + // - Ends with @ + const match = searchTerm.match( + /^(?:(?[a-z_][0-9a-z_]*(?:\.\d*)?)|@(?.*?)@?|@?(?.*?)?@)$/ + ); + if (!match) { + return undefined; + } + + const { groups } = match; + if (!groups) { + return undefined; + } + + return (groups.valid || groups.start || groups.end) ?? undefined; +} + +export function getNickname(username: string): string | undefined { + const match = username.match(/^(.*?)(?:\.|$)/); + if (!match) { + return undefined; + } + + return match[1]; +} + +export function getDiscriminator(username: string): string { + const match = username.match(/(\..*)$/); + if (!match) { + return ''; + } + + return match[1]; +} diff --git a/ts/updater/common.ts b/ts/updater/common.ts index e5d785106d..de5c645487 100644 --- a/ts/updater/common.ts +++ b/ts/updater/common.ts @@ -25,7 +25,7 @@ import type { BrowserWindow } from 'electron'; import { app, ipcMain } from 'electron'; import * as durations from '../util/durations'; -import { getTempPath, getUpdateCachePath } from '../util/attachments'; +import { getTempPath, getUpdateCachePath } from '../../app/attachments'; import { DialogType } from '../types/Dialogs'; import * as Errors from '../types/errors'; import { isAlpha, isBeta, isStaging } from '../util/version'; diff --git a/ts/util/Username.ts b/ts/util/Username.ts index 5823edc7d5..a23d73898a 100644 --- a/ts/util/Username.ts +++ b/ts/util/Username.ts @@ -39,41 +39,3 @@ export function isValidUsername(username: string): boolean { const [, nickname] = match; return isValidNickname(nickname); } - -export function getUsernameFromSearch(searchTerm: string): string | undefined { - // Search term contains username if it: - // - Is a valid username with or without a discriminator - // - Starts with @ - // - Ends with @ - const match = searchTerm.match( - /^(?:(?[a-z_][0-9a-z_]*(?:\.\d*)?)|@(?.*?)@?|@?(?.*?)?@)$/ - ); - if (!match) { - return undefined; - } - - const { groups } = match; - if (!groups) { - return undefined; - } - - return (groups.valid || groups.start || groups.end) ?? undefined; -} - -export function getNickname(username: string): string | undefined { - const match = username.match(/^(.*?)(?:\.|$)/); - if (!match) { - return undefined; - } - - return match[1]; -} - -export function getDiscriminator(username: string): string { - const match = username.match(/(\..*)$/); - if (!match) { - return ''; - } - - return match[1]; -} diff --git a/ts/util/attachments.ts b/ts/util/attachments.ts index e2a7bb5653..8cf3492a99 100644 --- a/ts/util/attachments.ts +++ b/ts/util/attachments.ts @@ -1,71 +1,99 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { isString } from 'lodash'; -import { join, normalize } from 'path'; -import fse from 'fs-extra'; +import { omit } from 'lodash'; +import { blobToArrayBuffer } from 'blob-util'; +import * as log from '../logging/log'; +import { getValue } from '../RemoteConfig'; -import { isPathInside } from './isPathInside'; +import { parseIntOrThrow } from './parseIntOrThrow'; +import { scaleImageToLevel } from './scaleImageToLevel'; +import { isRecord } from './isRecord'; +import type { AttachmentType } from '../types/Attachment'; +import { canBeTranscoded } from '../types/Attachment'; +import type { LoggerType } from '../types/Logging'; +import * as MIME from '../types/MIME'; -const PATH = 'attachments.noindex'; -const AVATAR_PATH = 'avatars.noindex'; -const BADGES_PATH = 'badges.noindex'; -const STICKER_PATH = 'stickers.noindex'; -const TEMP_PATH = 'temp'; -const UPDATE_CACHE_PATH = 'update-cache'; -const DRAFT_PATH = 'drafts.noindex'; +const MEBIBYTE = 1024 * 1024; +const DEFAULT_MAX = 100 * MEBIBYTE; -const CACHED_PATHS = new Map(); +export const getMaximumAttachmentSize = (): number => { + try { + return parseIntOrThrow( + getValue('global.attachments.maxBytes'), + 'preProcessAttachment/maxAttachmentSize' + ); + } catch (error) { + log.warn( + 'Failed to parse integer out of global.attachments.maxBytes feature flag' + ); + return DEFAULT_MAX; + } +}; -const createPathGetter = - (subpath: string) => - (userDataPath: string): string => { - if (!isString(userDataPath)) { - throw new TypeError("'userDataPath' must be a string"); - } - - const naivePath = join(userDataPath, subpath); - - const cached = CACHED_PATHS.get(naivePath); - if (cached) { - return cached; - } - - let result = naivePath; - if (fse.pathExistsSync(naivePath)) { - result = fse.realpathSync(naivePath); - } - - CACHED_PATHS.set(naivePath, result); - - return result; - }; - -export const getAvatarsPath = createPathGetter(AVATAR_PATH); -export const getBadgesPath = createPathGetter(BADGES_PATH); -export const getDraftPath = createPathGetter(DRAFT_PATH); -export const getPath = createPathGetter(PATH); -export const getStickersPath = createPathGetter(STICKER_PATH); -export const getTempPath = createPathGetter(TEMP_PATH); -export const getUpdateCachePath = createPathGetter(UPDATE_CACHE_PATH); - -export const createDeleter = ( - root: string -): ((relativePath: string) => Promise) => { - if (!isString(root)) { - throw new TypeError("'root' must be a path"); +// Upgrade steps +// NOTE: This step strips all EXIF metadata from JPEG images as +// part of re-encoding the image: +export async function autoOrientJPEG( + attachment: AttachmentType, + { logger }: { logger: LoggerType }, + { + sendHQImages = false, + isIncoming = false, + }: { + sendHQImages?: boolean; + isIncoming?: boolean; + } = {} +): Promise { + if (isIncoming && !MIME.isJPEG(attachment.contentType)) { + return attachment; } - return async (relativePath: string): Promise => { - if (!isString(relativePath)) { - throw new TypeError("'relativePath' must be a string"); - } + if (!canBeTranscoded(attachment)) { + return attachment; + } - const absolutePath = join(root, relativePath); - const normalized = normalize(absolutePath); - if (!isPathInside(normalized, root)) { - throw new Error('Invalid relative path'); - } - await fse.remove(absolutePath); - }; -}; + // If we haven't downloaded the attachment yet, we won't have the data. + // All images go through handleImageAttachment before being sent and thus have + // already been scaled to level, oriented, stripped of exif data, and saved + // in high quality format. If we want to send the image in HQ we can return + // the attachment as-is. Otherwise we'll have to further scale it down. + if (!attachment.data || sendHQImages) { + return attachment; + } + + const dataBlob = new Blob([attachment.data], { + type: attachment.contentType, + }); + try { + const { blob: xcodedDataBlob } = await scaleImageToLevel( + dataBlob, + attachment.contentType, + isIncoming + ); + const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob); + + // IMPORTANT: We overwrite the existing `data` `Uint8Array` losing the original + // image data. Ideally, we’d preserve the original image data for users who want to + // retain it but due to reports of data loss, we don’t want to overburden IndexedDB + // by potentially doubling stored image data. + // See: https://github.com/signalapp/Signal-Desktop/issues/1589 + const xcodedAttachment = { + // `digest` is no longer valid for auto-oriented image data, so we discard it: + ...omit(attachment, 'digest'), + data: new Uint8Array(xcodedDataArrayBuffer), + size: xcodedDataArrayBuffer.byteLength, + }; + + return xcodedAttachment; + } catch (error: unknown) { + const errorString = + isRecord(error) && 'stack' in error ? error.stack : error; + logger.error( + 'autoOrientJPEG: Failed to rotate/scale attachment', + errorString + ); + + return attachment; + } +} diff --git a/ts/util/isAttachmentSizeOkay.ts b/ts/util/isAttachmentSizeOkay.ts index e2cde52578..adcacc01ee 100644 --- a/ts/util/isAttachmentSizeOkay.ts +++ b/ts/util/isAttachmentSizeOkay.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { AttachmentType } from '../types/Attachment'; -import { getMaximumAttachmentSize } from '../types/Attachment'; +import { getMaximumAttachmentSize } from './attachments'; import { showToast } from './showToast'; import { ToastFileSize } from '../components/ToastFileSize'; diff --git a/ts/util/processAttachment.ts b/ts/util/processAttachment.ts index bda5406b26..3d28766f94 100644 --- a/ts/util/processAttachment.ts +++ b/ts/util/processAttachment.ts @@ -8,7 +8,7 @@ import type { AttachmentDraftType, InMemoryAttachmentDraftType, } from '../types/Attachment'; -import { getMaximumAttachmentSize } from '../types/Attachment'; +import { getMaximumAttachmentSize } from './attachments'; import { AttachmentToastType } from '../types/AttachmentToastType'; import { fileToBytes } from './fileToBytes'; import { handleImageAttachment } from './handleImageAttachment'; diff --git a/ts/windows/attachments.ts b/ts/windows/attachments.ts index d4bbc02857..ab57b70bb2 100644 --- a/ts/windows/attachments.ts +++ b/ts/windows/attachments.ts @@ -14,7 +14,7 @@ import { isPathInside } from '../util/isPathInside'; import { writeWindowsZoneIdentifier } from '../util/windowsZoneIdentifier'; import { isWindows } from '../OS'; -export * from '../util/attachments'; +export * from '../../app/attachments'; type FSAttrType = { set: (path: string, attribute: string, value: string) => Promise;